mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
lint/setti
...
feat/criti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7900eb60 | ||
|
|
73b25de13a |
@@ -39,9 +39,9 @@ from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE, Prov
|
||||
from openhands.app_server.integrations.service_types import Comment
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
@@ -27,9 +27,9 @@ from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE, Prov
|
||||
from openhands.app_server.integrations.service_types import Comment
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
CONFIDENTIAL_NOTE = 'confidential_note'
|
||||
|
||||
@@ -42,13 +42,13 @@ from storage.jira_integration_store import JiraIntegrationStore
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
@@ -7,7 +7,7 @@ from jinja2 import Environment
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
|
||||
@@ -41,9 +41,9 @@ from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
@@ -31,7 +31,6 @@ from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.integrations.service_types import Repository
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
@@ -39,6 +38,7 @@ from openhands.server.types import (
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from jinja2 import Environment
|
||||
from storage.jira_dc_user import JiraDcUser
|
||||
from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class JiraDcViewInterface(ABC):
|
||||
|
||||
@@ -33,9 +33,9 @@ from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
integration_store = JiraDcIntegrationStore.get_instance()
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from openhands.app_server.integrations.provider import (
|
||||
from openhands.app_server.integrations.service_types import ProviderType, UserGitInfo
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class ResolverUserContext(UserContext):
|
||||
|
||||
@@ -36,7 +36,6 @@ from openhands.app_server.integrations.service_types import (
|
||||
ProviderTimeoutError,
|
||||
Repository,
|
||||
)
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import config, server_config, sio
|
||||
from openhands.server.types import (
|
||||
@@ -44,6 +43,7 @@ from openhands.server.types import (
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
authorize_url_generator = AuthorizeUrlGenerator(
|
||||
client_id=SLACK_CLIENT_ID,
|
||||
|
||||
@@ -5,7 +5,7 @@ from integrations.types import SummaryExtractionTracker
|
||||
from jinja2 import Environment
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -34,9 +34,9 @@ from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
|
||||
# =================================================
|
||||
|
||||
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
|
||||
from integrations.models import Message
|
||||
|
||||
from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class GitLabResourceType(Enum):
|
||||
|
||||
@@ -7,8 +7,8 @@ from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
def is_budget_exceeded_error(error_message: str) -> bool:
|
||||
|
||||
@@ -40,8 +40,8 @@ from storage.org_member_store import OrgMemberStore
|
||||
from storage.role import Role
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
|
||||
|
||||
class Permission(str, Enum):
|
||||
|
||||
@@ -43,7 +43,7 @@ from openhands.app_server.integrations.provider import (
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.app_server.user_auth.user_auth import AuthType, UserAuth
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth
|
||||
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ Email domain validation utilities for enterprise endpoints.
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from openhands.app_server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
|
||||
|
||||
async def get_admin_user_id(
|
||||
|
||||
@@ -15,9 +15,9 @@ from server.auth.saas_user_auth import SaasUserAuth, token_manager
|
||||
from server.routes.auth import set_response_cookie
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
|
||||
|
||||
|
||||
class SetAuthCookieMiddleware:
|
||||
|
||||
@@ -12,9 +12,9 @@ from storage.org_member_store import OrgMemberStore
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.app_server.user_auth.user_auth import AuthType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
# Helper functions for BYOR API key management
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
import uuid
|
||||
import warnings
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Optional, cast
|
||||
from urllib.parse import quote, urlencode
|
||||
from uuid import UUID as parse_uuid
|
||||
@@ -47,16 +46,13 @@ from storage.database import a_session_maker
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
ProviderToken,
|
||||
)
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.integrations.service_types import ProviderType, TokenResponse
|
||||
from openhands.app_server.user_auth import get_access_token
|
||||
from openhands.app_server.user_auth.user_auth import get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.services.conversation_service import create_provider_tokens_object
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import get_access_token
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
@@ -67,18 +63,6 @@ oauth_router = APIRouter(prefix='/oauth')
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
def create_provider_tokens_object(
|
||||
providers_set: list[ProviderType],
|
||||
) -> PROVIDER_TOKEN_TYPE:
|
||||
"""Create provider tokens object for the given providers."""
|
||||
provider_information: dict[ProviderType, ProviderToken] = {}
|
||||
|
||||
for provider in providers_set:
|
||||
provider_information[provider] = ProviderToken(token=None, user_id=None)
|
||||
|
||||
return MappingProxyType(provider_information)
|
||||
|
||||
|
||||
def set_response_cookie(
|
||||
request: Request,
|
||||
response: Response,
|
||||
|
||||
@@ -21,7 +21,7 @@ from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.config import get_global_config
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
stripe.api_key = STRIPE_API_KEY
|
||||
billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])
|
||||
|
||||
@@ -13,9 +13,9 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from server.utils.url_utils import get_web_url
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.app_server.user_auth.user_auth import get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Email validation regex pattern
|
||||
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||
|
||||
@@ -20,9 +20,9 @@ from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import sio
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
gitlab_integration_router = APIRouter(prefix='/integration')
|
||||
webhook_store = GitlabWebhookStore()
|
||||
|
||||
@@ -20,8 +20,8 @@ from server.auth.token_manager import TokenManager
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
from storage.redis import create_redis_client
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Environment variable to disable Jira webhooks
|
||||
JIRA_WEBHOOKS_ENABLED = os.environ.get('JIRA_WEBHOOKS_ENABLED', '0') in (
|
||||
|
||||
@@ -28,8 +28,8 @@ from server.auth.token_manager import TokenManager
|
||||
from server.constants import WEB_HOST
|
||||
from storage.redis import create_redis_client
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Environment variable to disable Jira DC webhooks
|
||||
JIRA_DC_WEBHOOKS_ENABLED = os.environ.get('JIRA_DC_WEBHOOKS_ENABLED', '0') in (
|
||||
|
||||
@@ -10,8 +10,8 @@ from server.utils.url_utils import get_web_url
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
||||
@@ -22,8 +22,8 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# Router for invitation operations on an organization (requires org_id)
|
||||
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
|
||||
|
||||
@@ -50,8 +50,8 @@ from storage.org_service import OrgService
|
||||
from storage.org_store import OrgStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# Initialize API router
|
||||
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
|
||||
|
||||
@@ -19,7 +19,7 @@ from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.app_server.integrations.service_types import ProviderType, Repository
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -17,7 +17,7 @@ from storage.jira_dc_user import JiraDcUser
|
||||
from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
|
||||
from openhands.app_server.integrations.service_types import ProviderType, Repository
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -17,7 +17,7 @@ from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
|
||||
@@ -19,7 +19,7 @@ from server.routes.api_keys import (
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import AuthType
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
class TestVerifyByorKeyInLitellm:
|
||||
|
||||
@@ -22,7 +22,7 @@ from server.routes.orgs import (
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org_git_claim import OrgGitClaim
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
TEST_USER_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ from server.routes.orgs import (
|
||||
)
|
||||
from storage.org import Org
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# Test user ID constant (must be a valid UUID string)
|
||||
TEST_USER_ID = str(uuid.uuid4())
|
||||
|
||||
@@ -16,7 +16,7 @@ from server.routes.user_app_settings_models import (
|
||||
UserNotFoundError,
|
||||
)
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
TEST_USER_ID = str(uuid.uuid4())
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ class TestGetOrgInfoFromContext:
|
||||
from server.routes.users_v1 import _get_org_info_from_context
|
||||
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
# Create AuthUserContext with a non-SaasUserAuth
|
||||
mock_user_auth = MagicMock(spec=UserAuth)
|
||||
|
||||
@@ -13,7 +13,7 @@ from server.auth.auth_error import (
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.middleware import SetAuthCookieMiddleware
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import AuthType
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -799,7 +799,7 @@ async def test_logout_without_refresh_token():
|
||||
|
||||
with patch('server.routes.auth.token_manager') as mock_token_manager:
|
||||
with patch(
|
||||
'openhands.app_server.user_auth.default_user_auth.DefaultUserAuth.get_instance'
|
||||
'openhands.server.user_auth.default_user_auth.DefaultUserAuth.get_instance'
|
||||
) as mock_get_instance:
|
||||
mock_get_instance.side_effect = AuthError()
|
||||
result = await logout(mock_request)
|
||||
|
||||
@@ -68,7 +68,7 @@ class TestAcceptInvitationPostEndpoint:
|
||||
def auth_app(self):
|
||||
"""Create a FastAPI app with dependency overrides for authenticated tests."""
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(accept_router)
|
||||
@@ -200,7 +200,7 @@ class TestCreateInvitationBatchEndpoint:
|
||||
@pytest.fixture
|
||||
def batch_app(self):
|
||||
"""Create a FastAPI app with dependency overrides for batch tests."""
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(invitation_router)
|
||||
|
||||
@@ -16,7 +16,7 @@ from openhands.app_server.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
)
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"excludedFiles": ["src/hooks/query/query-keys.ts"],
|
||||
"rules": {
|
||||
// Allow state modification in reduce and Redux reducers
|
||||
"no-param-reassign": [
|
||||
@@ -49,13 +48,8 @@
|
||||
"ignorePropertyModificationsFor": ["acc", "state"],
|
||||
},
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"selector": "Property[key.name='queryKey'] > ArrayExpression[elements.0.value='settings']",
|
||||
"message": "Use SETTINGS_QUERY_KEYS helpers instead of raw settings query key arrays."
|
||||
}
|
||||
],
|
||||
// For https://stackoverflow.com/questions/55844608/stuck-with-eslint-error-i-e-separately-loops-should-be-avoided-in-favor-of-arra
|
||||
"no-restricted-syntax": "off",
|
||||
"react/require-default-props": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { CriticResultDisplay } from "#/components/v1/chat/event-message-components/critic-result-display";
|
||||
import type { CriticResult } from "#/types/v1/core/base/critic";
|
||||
|
||||
const makeCriticResult = (
|
||||
overrides: Partial<CriticResult> = {},
|
||||
): CriticResult => ({
|
||||
score: 0.85,
|
||||
message: null,
|
||||
metadata: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("CriticResultDisplay", () => {
|
||||
it("renders score as percentage", () => {
|
||||
renderWithProviders(
|
||||
<CriticResultDisplay criticResult={makeCriticResult({ score: 0.72 })} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("(72.0%)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 5 stars for a perfect score", () => {
|
||||
renderWithProviders(
|
||||
<CriticResultDisplay criticResult={makeCriticResult({ score: 1.0 })} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("★★★★★")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 0 stars for a zero score", () => {
|
||||
renderWithProviders(
|
||||
<CriticResultDisplay criticResult={makeCriticResult({ score: 0 })} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("☆☆☆☆☆")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders green color for high score", () => {
|
||||
renderWithProviders(
|
||||
<CriticResultDisplay criticResult={makeCriticResult({ score: 0.8 })} />,
|
||||
);
|
||||
|
||||
const stars = screen.getByText("★★★★☆");
|
||||
expect(stars.className).toContain("text-green-400");
|
||||
});
|
||||
|
||||
it("renders yellow color for medium score", () => {
|
||||
renderWithProviders(
|
||||
<CriticResultDisplay criticResult={makeCriticResult({ score: 0.5 })} />,
|
||||
);
|
||||
|
||||
const stars = screen.getByText("★★★☆☆");
|
||||
expect(stars.className).toContain("text-yellow-400");
|
||||
});
|
||||
|
||||
it("renders red color for low score", () => {
|
||||
renderWithProviders(
|
||||
<CriticResultDisplay criticResult={makeCriticResult({ score: 0.2 })} />,
|
||||
);
|
||||
|
||||
const stars = screen.getByText("★☆☆☆☆");
|
||||
expect(stars.className).toContain("text-red-400");
|
||||
});
|
||||
|
||||
it("renders label text", () => {
|
||||
renderWithProviders(
|
||||
<CriticResultDisplay criticResult={makeCriticResult()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("CRITIC$SUCCESS_LIKELIHOOD_LABEL"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render expand button without features", () => {
|
||||
renderWithProviders(
|
||||
<CriticResultDisplay criticResult={makeCriticResult()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByLabelText("Expand details"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders expand button when features are present", () => {
|
||||
const result = makeCriticResult({
|
||||
metadata: {
|
||||
categorized_features: {
|
||||
agent_behavioral_issues: [
|
||||
{
|
||||
name: "insufficient_testing",
|
||||
display_name: "Insufficient Testing",
|
||||
probability: 0.75,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<CriticResultDisplay criticResult={result} />);
|
||||
|
||||
expect(screen.getByLabelText("Expand details")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands features on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const result = makeCriticResult({
|
||||
metadata: {
|
||||
categorized_features: {
|
||||
agent_behavioral_issues: [
|
||||
{
|
||||
name: "insufficient_testing",
|
||||
display_name: "Insufficient Testing",
|
||||
probability: 0.75,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<CriticResultDisplay criticResult={result} />);
|
||||
|
||||
expect(
|
||||
screen.queryByText("Insufficient Testing"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Expand details"));
|
||||
|
||||
expect(screen.getByText("Insufficient Testing")).toBeInTheDocument();
|
||||
expect(screen.getByText("(75%)")).toBeInTheDocument();
|
||||
expect(screen.getByText("CRITIC$POTENTIAL_ISSUES")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses features on second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const result = makeCriticResult({
|
||||
metadata: {
|
||||
categorized_features: {
|
||||
agent_behavioral_issues: [
|
||||
{
|
||||
name: "loop_behavior",
|
||||
display_name: "Loop Behavior",
|
||||
probability: 0.6,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<CriticResultDisplay criticResult={result} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Expand details"));
|
||||
expect(screen.getByText("Loop Behavior")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Collapse details"));
|
||||
expect(screen.queryByText("Loop Behavior")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple categories of features", async () => {
|
||||
const user = userEvent.setup();
|
||||
const result = makeCriticResult({
|
||||
metadata: {
|
||||
categorized_features: {
|
||||
agent_behavioral_issues: [
|
||||
{
|
||||
name: "incomplete_changes",
|
||||
display_name: "Incomplete Changes",
|
||||
probability: 0.8,
|
||||
},
|
||||
],
|
||||
infrastructure_issues: [
|
||||
{
|
||||
name: "build_failure",
|
||||
display_name: "Build Failure",
|
||||
probability: 0.4,
|
||||
},
|
||||
],
|
||||
user_followup_patterns: [
|
||||
{
|
||||
name: "will_ask_refinement",
|
||||
display_name: "Will Ask Refinement",
|
||||
probability: 0.55,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithProviders(<CriticResultDisplay criticResult={result} />);
|
||||
|
||||
await user.click(screen.getByLabelText("Expand details"));
|
||||
|
||||
expect(screen.getByText("CRITIC$POTENTIAL_ISSUES")).toBeInTheDocument();
|
||||
expect(screen.getByText("Incomplete Changes")).toBeInTheDocument();
|
||||
expect(screen.getByText("CRITIC$INFRASTRUCTURE")).toBeInTheDocument();
|
||||
expect(screen.getByText("Build Failure")).toBeInTheDocument();
|
||||
expect(screen.getByText("CRITIC$LIKELY_FOLLOWUP")).toBeInTheDocument();
|
||||
expect(screen.getByText("Will Ask Refinement")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,10 +13,17 @@ function buildSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
...overrides,
|
||||
agent_settings: {
|
||||
...MOCK_DEFAULT_USER_SETTINGS.agent_settings,
|
||||
...overrides.agent_settings,
|
||||
},
|
||||
conversation_settings: {
|
||||
...MOCK_DEFAULT_USER_SETTINGS.conversation_settings,
|
||||
...overrides.conversation_settings,
|
||||
},
|
||||
agent_settings_schema:
|
||||
overrides.agent_settings_schema ??
|
||||
MOCK_DEFAULT_USER_SETTINGS.agent_settings_schema,
|
||||
conversation_settings_schema:
|
||||
overrides.conversation_settings_schema ??
|
||||
MOCK_DEFAULT_USER_SETTINGS.conversation_settings_schema,
|
||||
@@ -42,7 +49,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("VerificationSettingsScreen", () => {
|
||||
it("keeps confirmation mode visible in the basic view", async () => {
|
||||
it("keeps confirmation mode toggle visible in the header", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(buildSettings());
|
||||
|
||||
renderVerificationSettingsScreen();
|
||||
@@ -50,14 +57,15 @@ describe("VerificationSettingsScreen", () => {
|
||||
await screen.findByTestId("verification-settings-screen");
|
||||
|
||||
expect(
|
||||
screen.getByTestId("sdk-settings-confirmation_mode"),
|
||||
screen.getByTestId("confirmation-mode-toggle"),
|
||||
).toBeInTheDocument();
|
||||
// Security analyzer is hidden when confirmation mode is off
|
||||
expect(
|
||||
screen.queryByTestId("sdk-settings-security_analyzer"),
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the security analyzer only in advanced view", async () => {
|
||||
it("shows the security analyzer when confirmation mode is enabled", async () => {
|
||||
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
|
||||
buildSettings({
|
||||
conversation_settings: {
|
||||
@@ -72,17 +80,11 @@ describe("VerificationSettingsScreen", () => {
|
||||
|
||||
await screen.findByTestId("verification-settings-screen");
|
||||
|
||||
// Security analyzer should appear because confirmation mode is on
|
||||
expect(
|
||||
screen.queryByTestId("sdk-settings-security_analyzer"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId("sdk-section-advanced-toggle"));
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("sdk-settings-security_analyzer"),
|
||||
screen.getByTestId("security-analyzer-input"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("clientLoader permission checks", () => {
|
||||
|
||||
@@ -14,7 +14,12 @@ import { useMe } from "#/hooks/query/use-me";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { Settings, SettingsSchema, SettingsScope } from "#/types/settings";
|
||||
import {
|
||||
SettingProminence,
|
||||
Settings,
|
||||
SettingsSchema,
|
||||
SettingsScope,
|
||||
} from "#/types/settings";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
@@ -104,6 +109,7 @@ export function SdkSectionPage({
|
||||
buildPayload,
|
||||
onSaveSuccess,
|
||||
getInitialView,
|
||||
prominenceOverrides,
|
||||
forceShowAdvancedView = false,
|
||||
allowAllView = true,
|
||||
testId = "sdk-section-settings-screen",
|
||||
@@ -128,6 +134,8 @@ export function SdkSectionPage({
|
||||
settings: Settings,
|
||||
filteredSchema: SettingsSchema,
|
||||
) => SettingsView;
|
||||
/** Override the prominence level of specific field keys. */
|
||||
prominenceOverrides?: Record<string, SettingProminence>;
|
||||
forceShowAdvancedView?: boolean;
|
||||
allowAllView?: boolean;
|
||||
testId?: string;
|
||||
@@ -175,11 +183,23 @@ export function SdkSectionPage({
|
||||
const filteredSchema = React.useMemo(() => {
|
||||
if (!schema) return null;
|
||||
const sectionSet = new Set(stableSectionKeys);
|
||||
return {
|
||||
const filtered = {
|
||||
...schema,
|
||||
sections: schema.sections.filter((s) => sectionSet.has(s.key)),
|
||||
};
|
||||
}, [schema, stableSectionKeys]);
|
||||
if (!prominenceOverrides) return filtered;
|
||||
return {
|
||||
...filtered,
|
||||
sections: filtered.sections.map((s) => ({
|
||||
...s,
|
||||
fields: s.fields.map((f) =>
|
||||
prominenceOverrides[f.key]
|
||||
? { ...f, prominence: prominenceOverrides[f.key] }
|
||||
: f,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}, [schema, stableSectionKeys, prominenceOverrides]);
|
||||
|
||||
const showAdvanced =
|
||||
forceShowAdvancedView || hasAdvancedSettings(filteredSchema);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowDown from "#/icons/angle-down-solid.svg?react";
|
||||
import ArrowUp from "#/icons/angle-up-solid.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type {
|
||||
CriticResult,
|
||||
CriticFeature,
|
||||
CriticCategorizedFeatures,
|
||||
} from "#/types/v1/core/base/critic";
|
||||
|
||||
/**
|
||||
* Convert a score (0-1) to a 5-star rating string.
|
||||
*/
|
||||
function getStarRating(score: number): { filled: number; empty: number } {
|
||||
const filled = Math.round(score * 5);
|
||||
return { filled, empty: 5 - filled };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color class for the star rating based on score.
|
||||
*/
|
||||
function getScoreColorClass(score: number): string {
|
||||
if (score >= 0.6) return "text-green-400";
|
||||
if (score >= 0.4) return "text-yellow-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color class for an issue probability.
|
||||
*/
|
||||
function getIssueColorClass(probability: number): string {
|
||||
if (probability >= 0.7) return "text-red-400 font-semibold";
|
||||
if (probability >= 0.5) return "text-yellow-400";
|
||||
return "text-neutral-400";
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single issue feature with its probability.
|
||||
*/
|
||||
function FeatureItem({ feature }: { feature: CriticFeature }) {
|
||||
const percentage = Math.round(feature.probability * 100);
|
||||
const colorClass = getIssueColorClass(feature.probability);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-neutral-200">{feature.display_name}</span>
|
||||
<span className={colorClass}>({percentage}%)</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a category of features (e.g., "Potential Issues", "Infrastructure").
|
||||
*/
|
||||
function FeatureCategory({
|
||||
label,
|
||||
features,
|
||||
}: {
|
||||
label: string;
|
||||
features: CriticFeature[];
|
||||
}) {
|
||||
if (!features || features.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-1 text-xs">
|
||||
<span className="font-semibold text-neutral-300">{label}</span>
|
||||
{features.map((feature, i) => (
|
||||
<React.Fragment key={feature.name}>
|
||||
{i > 0 && <span className="text-neutral-500">·</span>}
|
||||
<FeatureItem feature={feature} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the categorized features breakdown.
|
||||
*/
|
||||
function FeaturesBreakdown({
|
||||
categorized,
|
||||
}: {
|
||||
categorized: CriticCategorizedFeatures;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasAgentIssues =
|
||||
categorized.agent_behavioral_issues &&
|
||||
categorized.agent_behavioral_issues.length > 0;
|
||||
const hasUserPatterns =
|
||||
categorized.user_followup_patterns &&
|
||||
categorized.user_followup_patterns.length > 0;
|
||||
const hasInfra =
|
||||
categorized.infrastructure_issues &&
|
||||
categorized.infrastructure_issues.length > 0;
|
||||
const hasOther = categorized.other && categorized.other.length > 0;
|
||||
|
||||
if (!hasAgentIssues && !hasUserPatterns && !hasInfra && !hasOther) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mt-1.5">
|
||||
{hasAgentIssues && (
|
||||
<FeatureCategory
|
||||
label={t(I18nKey.CRITIC$POTENTIAL_ISSUES)}
|
||||
features={categorized.agent_behavioral_issues!}
|
||||
/>
|
||||
)}
|
||||
{hasInfra && (
|
||||
<FeatureCategory
|
||||
label={t(I18nKey.CRITIC$INFRASTRUCTURE)}
|
||||
features={categorized.infrastructure_issues!}
|
||||
/>
|
||||
)}
|
||||
{hasUserPatterns && (
|
||||
<FeatureCategory
|
||||
label={t(I18nKey.CRITIC$LIKELY_FOLLOWUP)}
|
||||
features={categorized.user_followup_patterns!}
|
||||
/>
|
||||
)}
|
||||
{hasOther && (
|
||||
<FeatureCategory
|
||||
label={t(I18nKey.CRITIC$OTHER)}
|
||||
features={categorized.other!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CriticResultDisplayProps {
|
||||
criticResult: CriticResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a critic evaluation result with star rating, score percentage,
|
||||
* and expandable categorized feature breakdown.
|
||||
*/
|
||||
export function CriticResultDisplay({
|
||||
criticResult,
|
||||
}: CriticResultDisplayProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
const { filled, empty } = getStarRating(criticResult.score);
|
||||
const colorClass = getScoreColorClass(criticResult.score);
|
||||
const percentage = (criticResult.score * 100).toFixed(1);
|
||||
|
||||
const categorized = criticResult.metadata?.categorized_features;
|
||||
const hasDetails =
|
||||
categorized != null &&
|
||||
((categorized.agent_behavioral_issues ?? []).length > 0 ||
|
||||
(categorized.user_followup_patterns ?? []).length > 0 ||
|
||||
(categorized.infrastructure_issues ?? []).length > 0 ||
|
||||
(categorized.other ?? []).length > 0);
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-neutral-600 pl-2 my-2 py-1.5 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold text-neutral-300 text-xs">
|
||||
{t(I18nKey.CRITIC$SUCCESS_LIKELIHOOD_LABEL)}
|
||||
</span>
|
||||
<span className={`${colorClass} text-xs tracking-wide`}>
|
||||
{"★".repeat(filled)}
|
||||
{"☆".repeat(empty)}
|
||||
</span>
|
||||
<span className="text-neutral-500 text-xs">({percentage}%)</span>
|
||||
|
||||
{hasDetails && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="cursor-pointer ml-1"
|
||||
aria-label={expanded ? "Collapse details" : "Expand details"}
|
||||
>
|
||||
{expanded ? (
|
||||
<ArrowUp className="h-3 w-3 inline fill-neutral-400" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3 inline fill-neutral-400" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && hasDetails && (
|
||||
<FeaturesBreakdown categorized={categorized!} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ActionEvent } from "#/types/v1/core";
|
||||
import { FinishAction } from "#/types/v1/core/base/action";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { CriticResultDisplay } from "./critic-result-display";
|
||||
|
||||
interface FinishEventMessageProps {
|
||||
event: ActionEvent<FinishAction>;
|
||||
@@ -19,10 +20,15 @@ export function FinishEventMessage({
|
||||
: String(eventContent.details);
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={message}
|
||||
isFromPlanningAgent={isFromPlanningAgent}
|
||||
/>
|
||||
<>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={message}
|
||||
isFromPlanningAgent={isFromPlanningAgent}
|
||||
/>
|
||||
{event.critic_result != null && (
|
||||
<CriticResultDisplay criticResult={event.critic_result} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,3 +5,4 @@ export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { ThoughtEventMessage } from "./thought-event-message";
|
||||
export { HookExecutionEventMessage } from "./hook-execution-event-message";
|
||||
export { CriticResultDisplay } from "./critic-result-display";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { ImageCarousel } from "../../../features/images/image-carousel";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event";
|
||||
import { CriticResultDisplay } from "./critic-result-display";
|
||||
|
||||
interface UserAssistantEventMessageProps {
|
||||
event: MessageEvent;
|
||||
@@ -28,15 +29,20 @@ export function UserAssistantEventMessage({
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
type={event.source}
|
||||
message={message}
|
||||
isFromPlanningAgent={isFromPlanningAgent}
|
||||
>
|
||||
{imageUrls.length > 0 && (
|
||||
<ImageCarousel size="small" images={imageUrls} />
|
||||
<>
|
||||
<ChatMessage
|
||||
type={event.source}
|
||||
message={message}
|
||||
isFromPlanningAgent={isFromPlanningAgent}
|
||||
>
|
||||
{imageUrls.length > 0 && (
|
||||
<ImageCarousel size="small" images={imageUrls} />
|
||||
)}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
{event.source === "agent" && event.critic_result != null && (
|
||||
<CriticResultDisplay criticResult={event.critic_result} />
|
||||
)}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { SecretsService } from "#/api/secrets-service";
|
||||
import { Provider, ProviderToken } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
|
||||
export const useAddGitProviders = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -29,7 +28,7 @@ export const useAddGitProviders = () => {
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
|
||||
queryKey: ["settings", "personal", organizationId],
|
||||
});
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "#/types/settings";
|
||||
import { parseMcpConfig, toSdkMcpConfig } from "#/utils/mcp-config";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
|
||||
@@ -70,7 +69,7 @@ export function useAddMcpServer() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
|
||||
queryKey: ["settings", "personal", organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
|
||||
export const useDeleteGitProviders = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -11,7 +10,7 @@ export const useDeleteGitProviders = () => {
|
||||
mutationFn: () => SecretsService.deleteGitProviders(),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
|
||||
queryKey: ["settings", "personal", organizationId],
|
||||
});
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -4,7 +4,6 @@ import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
import { parseMcpConfig, toSdkMcpConfig } from "#/utils/mcp-config";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
|
||||
export function useDeleteMcpServer() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -39,7 +38,7 @@ export function useDeleteMcpServer() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
|
||||
queryKey: ["settings", "personal", organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { clearLoginData } from "#/utils/local-storage";
|
||||
import { SETTINGS_QUERY_KEYS } from "../query/query-keys";
|
||||
import { useConfig } from "../query/use-config";
|
||||
import { clearLoginData } from "#/utils/local-storage";
|
||||
|
||||
export const useLogout = () => {
|
||||
const posthog = usePostHog();
|
||||
@@ -14,7 +13,7 @@ export const useLogout = () => {
|
||||
mutationFn: () => AuthService.logout(config?.app_mode ?? "oss"),
|
||||
onSuccess: async () => {
|
||||
queryClient.removeQueries({ queryKey: ["tasks"] });
|
||||
queryClient.removeQueries({ queryKey: SETTINGS_QUERY_KEYS.all });
|
||||
queryClient.removeQueries({ queryKey: ["settings"] });
|
||||
queryClient.removeQueries({ queryKey: ["user"] });
|
||||
queryClient.removeQueries({ queryKey: ["secrets"] });
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
SettingsValue,
|
||||
} from "#/types/settings";
|
||||
import { useSettings } from "../query/use-settings";
|
||||
import { SETTINGS_QUERY_KEYS } from "../query/query-keys";
|
||||
|
||||
type SettingsUpdate = Partial<Settings> & Record<string, unknown>;
|
||||
|
||||
@@ -102,11 +101,11 @@ export const useSaveSettings = (scope: SettingsScope = "personal") => {
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.byScope(scope, organizationId),
|
||||
queryKey: ["settings", scope, organizationId],
|
||||
});
|
||||
if (scope === "org") {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
|
||||
queryKey: ["settings", "personal", organizationId],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
type SubmitOnboardingArgs = {
|
||||
@@ -19,7 +18,7 @@ export const useSubmitOnboarding = () => {
|
||||
return { selections };
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEYS.all });
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["onboarding-status"] });
|
||||
|
||||
const finalRedirectUrl = "/";
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useSwitchOrganization = () => {
|
||||
queryKey: ["organizations", orgId, "me"],
|
||||
});
|
||||
// Update local state - this triggers automatic refetch for all org-scoped queries
|
||||
// since their query keys include the selected organizationId.
|
||||
// since their query keys include organizationId (e.g., ["settings", orgId], ["secrets", orgId])
|
||||
setOrganizationId(orgId);
|
||||
// Broadcast org change to other apps (e.g. Automations) via localStorage
|
||||
setSelectedOrg(orgId);
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "#/types/settings";
|
||||
import { parseMcpConfig, toSdkMcpConfig } from "#/utils/mcp-config";
|
||||
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
|
||||
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
|
||||
type MCPServerType = "sse" | "stdio" | "shttp";
|
||||
|
||||
@@ -78,7 +77,7 @@ export function useUpdateMcpServer() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
|
||||
queryKey: ["settings", "personal", organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,21 +3,11 @@
|
||||
* Using constants ensures type safety and prevents typos.
|
||||
*/
|
||||
|
||||
import { SettingsScope } from "#/types/settings";
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
/** Web client configuration from the server */
|
||||
WEB_CLIENT_CONFIG: ["web-client-config"] as const,
|
||||
} as const;
|
||||
|
||||
export const SETTINGS_QUERY_KEYS = {
|
||||
all: ["settings"] as const,
|
||||
byScope: (scope: SettingsScope, organizationId: string | null | undefined) =>
|
||||
["settings", scope, organizationId] as const,
|
||||
personal: (organizationId: string | null | undefined) =>
|
||||
["settings", "personal", organizationId] as const,
|
||||
} as const;
|
||||
|
||||
/** Cache configuration shared across all config-related queries */
|
||||
export const CONFIG_CACHE_OPTIONS = {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
pickNullableString,
|
||||
} from "#/utils/settings-value-pickers";
|
||||
import { parseMcpConfig } from "#/utils/mcp-config";
|
||||
import { SETTINGS_QUERY_KEYS } from "./query-keys";
|
||||
|
||||
/** Look up a value in a nested object by dotted key path. */
|
||||
const lookupNested = (obj: Record<string, unknown>, key: string): unknown => {
|
||||
@@ -131,7 +130,7 @@ export const useSettings = (scope: SettingsScope = "personal") => {
|
||||
const isOss = config?.app_mode === "oss";
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: SETTINGS_QUERY_KEYS.byScope(scope, organizationId),
|
||||
queryKey: ["settings", scope, organizationId],
|
||||
queryFn: () => getSettingsQueryFn(scope, organizationId),
|
||||
retry: (_, error) => error.status !== 404,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
@@ -42,6 +42,11 @@ export enum I18nKey {
|
||||
FINISH$TASK_COMPLETED_SUCCESSFULLY = "FINISH$TASK_COMPLETED_SUCCESSFULLY",
|
||||
FINISH$TASK_NOT_COMPLETED = "FINISH$TASK_NOT_COMPLETED",
|
||||
FINISH$TASK_COMPLETED_PARTIALLY = "FINISH$TASK_COMPLETED_PARTIALLY",
|
||||
CRITIC$SUCCESS_LIKELIHOOD_LABEL = "CRITIC$SUCCESS_LIKELIHOOD_LABEL",
|
||||
CRITIC$POTENTIAL_ISSUES = "CRITIC$POTENTIAL_ISSUES",
|
||||
CRITIC$INFRASTRUCTURE = "CRITIC$INFRASTRUCTURE",
|
||||
CRITIC$LIKELY_FOLLOWUP = "CRITIC$LIKELY_FOLLOWUP",
|
||||
CRITIC$OTHER = "CRITIC$OTHER",
|
||||
EVENT$UNKNOWN_EVENT = "EVENT$UNKNOWN_EVENT",
|
||||
OBSERVATION$COMMAND_NO_OUTPUT = "OBSERVATION$COMMAND_NO_OUTPUT",
|
||||
OBSERVATION$MCP_NO_OUTPUT = "OBSERVATION$MCP_NO_OUTPUT",
|
||||
|
||||
@@ -24053,5 +24053,90 @@
|
||||
"de": "Keine Git-Organisationen gefunden.",
|
||||
"uk": "Організації Git не знайдено.",
|
||||
"ca": "No s'han trobat organitzacions Git."
|
||||
},
|
||||
"CRITIC$SUCCESS_LIKELIHOOD_LABEL": {
|
||||
"en": "Critic: agent success likelihood",
|
||||
"ja": "クリティック: エージェント成功確率",
|
||||
"zh-CN": "评估: 代理成功概率",
|
||||
"zh-TW": "評估: 代理成功機率",
|
||||
"ko-KR": "평가: 에이전트 성공 가능성",
|
||||
"no": "Kritiker: sannsynlighet for agentsuksess",
|
||||
"it": "Critico: probabilità di successo dell'agente",
|
||||
"pt": "Crítico: probabilidade de sucesso do agente",
|
||||
"es": "Crítico: probabilidad de éxito del agente",
|
||||
"ar": "الناقد: احتمالية نجاح الوكيل",
|
||||
"fr": "Critique : probabilité de succès de l'agent",
|
||||
"tr": "Eleştirmen: ajan başarı olasılığı",
|
||||
"de": "Kritiker: Erfolgswahrscheinlichkeit des Agenten",
|
||||
"uk": "Критик: ймовірність успіху агента",
|
||||
"ca": "Crític: probabilitat d'èxit de l'agent"
|
||||
},
|
||||
"CRITIC$POTENTIAL_ISSUES": {
|
||||
"en": "Potential Issues:",
|
||||
"ja": "潜在的な問題:",
|
||||
"zh-CN": "潜在问题:",
|
||||
"zh-TW": "潛在問題:",
|
||||
"ko-KR": "잠재적 문제:",
|
||||
"no": "Potensielle problemer:",
|
||||
"it": "Problemi potenziali:",
|
||||
"pt": "Problemas potenciais:",
|
||||
"es": "Problemas potenciales:",
|
||||
"ar": "مشاكل محتملة:",
|
||||
"fr": "Problèmes potentiels :",
|
||||
"tr": "Olası sorunlar:",
|
||||
"de": "Mögliche Probleme:",
|
||||
"uk": "Потенційні проблеми:",
|
||||
"ca": "Problemes potencials:"
|
||||
},
|
||||
"CRITIC$INFRASTRUCTURE": {
|
||||
"en": "Infrastructure:",
|
||||
"ja": "インフラストラクチャ:",
|
||||
"zh-CN": "基础设施:",
|
||||
"zh-TW": "基礎設施:",
|
||||
"ko-KR": "인프라:",
|
||||
"no": "Infrastruktur:",
|
||||
"it": "Infrastruttura:",
|
||||
"pt": "Infraestrutura:",
|
||||
"es": "Infraestructura:",
|
||||
"ar": "البنية التحتية:",
|
||||
"fr": "Infrastructure :",
|
||||
"tr": "Altyapı:",
|
||||
"de": "Infrastruktur:",
|
||||
"uk": "Інфраструктура:",
|
||||
"ca": "Infraestructura:"
|
||||
},
|
||||
"CRITIC$LIKELY_FOLLOWUP": {
|
||||
"en": "Likely Follow-up:",
|
||||
"ja": "予想されるフォローアップ:",
|
||||
"zh-CN": "可能的后续操作:",
|
||||
"zh-TW": "可能的後續操作:",
|
||||
"ko-KR": "예상 후속 조치:",
|
||||
"no": "Sannsynlig oppfølging:",
|
||||
"it": "Probabile seguito:",
|
||||
"pt": "Provável acompanhamento:",
|
||||
"es": "Probable seguimiento:",
|
||||
"ar": "متابعة محتملة:",
|
||||
"fr": "Suivi probable :",
|
||||
"tr": "Olası takip:",
|
||||
"de": "Wahrscheinliche Nachverfolgung:",
|
||||
"uk": "Ймовірне продовження:",
|
||||
"ca": "Probable seguiment:"
|
||||
},
|
||||
"CRITIC$OTHER": {
|
||||
"en": "Other:",
|
||||
"ja": "その他:",
|
||||
"zh-CN": "其他:",
|
||||
"zh-TW": "其他:",
|
||||
"ko-KR": "기타:",
|
||||
"no": "Annet:",
|
||||
"it": "Altro:",
|
||||
"pt": "Outros:",
|
||||
"es": "Otros:",
|
||||
"ar": "أخرى:",
|
||||
"fr": "Autre :",
|
||||
"tr": "Diğer:",
|
||||
"de": "Sonstiges:",
|
||||
"uk": "Інше:",
|
||||
"ca": "Altres:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,75 @@ const MOCK_AGENT_SETTINGS_SCHEMA: NonNullable<
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "verification",
|
||||
label: "Verification",
|
||||
fields: [
|
||||
{
|
||||
key: "verification.confirmation_mode",
|
||||
label: "Confirmation mode",
|
||||
description:
|
||||
"Pause for confirmation before the agent performs high-risk actions.",
|
||||
section: "verification",
|
||||
section_label: "Verification",
|
||||
value_type: "boolean",
|
||||
default: false,
|
||||
choices: [],
|
||||
depends_on: [],
|
||||
prominence: "critical",
|
||||
secret: false,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "verification.security_analyzer",
|
||||
label: "Security analyzer",
|
||||
description:
|
||||
"Choose how OpenHands should analyze actions before asking for confirmation.",
|
||||
section: "verification",
|
||||
section_label: "Verification",
|
||||
value_type: "string",
|
||||
default: "llm",
|
||||
choices: [
|
||||
{ label: "llm", value: "llm" },
|
||||
{ label: "none", value: "none" },
|
||||
],
|
||||
depends_on: ["verification.confirmation_mode"],
|
||||
prominence: "major",
|
||||
secret: false,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "verification.critic_enabled",
|
||||
label: "Enable Critic",
|
||||
description:
|
||||
"Enable an additional critic pass to review the agent's work.",
|
||||
section: "verification",
|
||||
section_label: "Verification",
|
||||
value_type: "boolean",
|
||||
default: false,
|
||||
choices: [],
|
||||
depends_on: [],
|
||||
prominence: "critical",
|
||||
secret: false,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "verification.enable_iterative_refinement",
|
||||
label: "Enable Iterative Refinement",
|
||||
description:
|
||||
"Let the critic send the agent back to refine its work when issues are found.",
|
||||
section: "verification",
|
||||
section_label: "Verification",
|
||||
value_type: "boolean",
|
||||
default: false,
|
||||
choices: [],
|
||||
depends_on: ["verification.critic_enabled"],
|
||||
prominence: "minor",
|
||||
secret: false,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { SETTINGS_QUERY_KEYS } from "#/hooks/query/query-keys";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { useEmailVerification } from "#/hooks/use-email-verification";
|
||||
@@ -148,7 +147,7 @@ function UserSettingsScreen() {
|
||||
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
@@ -184,7 +183,7 @@ function UserSettingsScreen() {
|
||||
// Display toast notification instead of setting state
|
||||
displaySuccessToast(t("SETTINGS$EMAIL_SAVED_SUCCESSFULLY"));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: SETTINGS_QUERY_KEYS.personal(organizationId),
|
||||
queryKey: ["settings", organizationId],
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@@ -1,15 +1,108 @@
|
||||
import React from "react";
|
||||
import { SdkSectionPage } from "#/components/features/settings/sdk-settings/sdk-section-page";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import {
|
||||
SdkSectionHeaderProps,
|
||||
SdkSectionPage,
|
||||
} from "#/components/features/settings/sdk-settings/sdk-section-page";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { SettingsScope } from "#/types/settings";
|
||||
import { createPermissionGuard } from "#/utils/org/permission-guard";
|
||||
import { requireOrgDefaultsRedirect } from "#/utils/org/saas-redirect-to-org-defaults-guard";
|
||||
|
||||
// These fields are rendered manually in the header, so exclude them from
|
||||
// schema-driven rendering. Agent-settings keys are dot-prefixed with the
|
||||
// section name (e.g. "verification.confirmation_mode").
|
||||
const VERIFICATION_SCHEMA_EXCLUDE_KEYS = new Set([
|
||||
"verification.confirmation_mode",
|
||||
"verification.security_analyzer",
|
||||
]);
|
||||
|
||||
// Promote iterative refinement to Basic view so it's discoverable alongside "Enable Critic"
|
||||
const VERIFICATION_PROMINENCE_OVERRIDES: Record<
|
||||
string,
|
||||
"critical" | "major" | "minor"
|
||||
> = {
|
||||
"verification.enable_iterative_refinement": "critical",
|
||||
};
|
||||
|
||||
function VerificationSettingsHeader({
|
||||
confirmationMode,
|
||||
securityAnalyzer,
|
||||
isConversationSettingsDisabled,
|
||||
onConfirmationModeChange,
|
||||
onSecurityAnalyzerChange,
|
||||
renderTopContent,
|
||||
}: {
|
||||
confirmationMode: boolean;
|
||||
securityAnalyzer: string | null;
|
||||
isConversationSettingsDisabled: boolean;
|
||||
onConfirmationModeChange: (value: boolean) => void;
|
||||
onSecurityAnalyzerChange: (value: string | null) => void;
|
||||
renderTopContent?: () => React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-6">{renderTopContent?.()}</div>;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const securityAnalyzerItems = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "llm",
|
||||
label: t(I18nKey.SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT),
|
||||
},
|
||||
{
|
||||
key: "none",
|
||||
label: t(I18nKey.SETTINGS$SECURITY_ANALYZER_NONE),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const showSecurityAnalyzer = confirmationMode;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{renderTopContent?.()}
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SettingsSwitch
|
||||
testId="confirmation-mode-toggle"
|
||||
isToggled={confirmationMode}
|
||||
onToggle={onConfirmationModeChange}
|
||||
isDisabled={isConversationSettingsDisabled}
|
||||
>
|
||||
{t(I18nKey.SETTINGS_FORM$ENABLE_CONFIRMATION_MODE_LABEL)}
|
||||
</SettingsSwitch>
|
||||
<p className="text-tertiary-alt text-xs leading-5">
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE_TOOLTIP)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showSecurityAnalyzer ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security_analyzer"
|
||||
label={t(I18nKey.SETTINGS_FORM$SECURITY_ANALYZER_LABEL)}
|
||||
items={securityAnalyzerItems}
|
||||
selectedKey={securityAnalyzer ?? undefined}
|
||||
placeholder={t(I18nKey.SETTINGS$SECURITY_ANALYZER_PLACEHOLDER)}
|
||||
isDisabled={isConversationSettingsDisabled}
|
||||
onSelectionChange={(key) =>
|
||||
onSecurityAnalyzerChange(key ? String(key) : null)
|
||||
}
|
||||
/>
|
||||
<p className="text-tertiary-alt text-xs leading-5 max-w-[680px] ">
|
||||
{t(I18nKey.SETTINGS$SECURITY_ANALYZER_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationSettingsScreen({
|
||||
@@ -21,17 +114,97 @@ export function VerificationSettingsScreen({
|
||||
renderTopContent?: () => React.ReactNode;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { data: settings } = useSettings(scope);
|
||||
const [confirmationMode, setConfirmationMode] = React.useState(
|
||||
DEFAULT_SETTINGS.confirmation_mode,
|
||||
);
|
||||
const [securityAnalyzer, setSecurityAnalyzer] = React.useState<string | null>(
|
||||
DEFAULT_SETTINGS.security_analyzer,
|
||||
);
|
||||
const [confirmationModeDirty, setConfirmationModeDirty] =
|
||||
React.useState(false);
|
||||
const [securityAnalyzerDirty, setSecurityAnalyzerDirty] =
|
||||
React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setConfirmationMode(
|
||||
settings?.confirmation_mode ?? DEFAULT_SETTINGS.confirmation_mode,
|
||||
);
|
||||
setSecurityAnalyzer(
|
||||
settings?.security_analyzer ?? DEFAULT_SETTINGS.security_analyzer,
|
||||
);
|
||||
setConfirmationModeDirty(false);
|
||||
setSecurityAnalyzerDirty(false);
|
||||
}, [settings?.confirmation_mode, settings?.security_analyzer]);
|
||||
|
||||
const buildHeader = React.useCallback(
|
||||
() => <VerificationSettingsHeader renderTopContent={renderTopContent} />,
|
||||
[renderTopContent],
|
||||
({ isDisabled }: SdkSectionHeaderProps) => (
|
||||
<VerificationSettingsHeader
|
||||
confirmationMode={confirmationMode}
|
||||
securityAnalyzer={securityAnalyzer}
|
||||
isConversationSettingsDisabled={isDisabled}
|
||||
onConfirmationModeChange={(value) => {
|
||||
setConfirmationMode(value);
|
||||
setConfirmationModeDirty(true);
|
||||
}}
|
||||
onSecurityAnalyzerChange={(value) => {
|
||||
setSecurityAnalyzer(value);
|
||||
setSecurityAnalyzerDirty(true);
|
||||
}}
|
||||
renderTopContent={renderTopContent}
|
||||
/>
|
||||
),
|
||||
[confirmationMode, renderTopContent, securityAnalyzer],
|
||||
);
|
||||
|
||||
const buildPayload = React.useCallback(
|
||||
(basePayload: Record<string, unknown>) => {
|
||||
// Critic fields go into agent_settings_diff
|
||||
const result: Record<string, unknown> = {};
|
||||
if (Object.keys(basePayload).length > 0) {
|
||||
result.agent_settings_diff = basePayload;
|
||||
}
|
||||
|
||||
// Confirmation mode and security analyzer go into conversation_settings_diff
|
||||
const conversationDiff: Record<string, unknown> = {};
|
||||
if (confirmationModeDirty) {
|
||||
conversationDiff.confirmation_mode = confirmationMode;
|
||||
}
|
||||
if (
|
||||
securityAnalyzerDirty ||
|
||||
(confirmationMode && settings?.security_analyzer !== securityAnalyzer)
|
||||
) {
|
||||
conversationDiff.security_analyzer = securityAnalyzer;
|
||||
}
|
||||
if (Object.keys(conversationDiff).length > 0) {
|
||||
result.conversation_settings_diff = conversationDiff;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
[
|
||||
confirmationMode,
|
||||
confirmationModeDirty,
|
||||
securityAnalyzer,
|
||||
securityAnalyzerDirty,
|
||||
settings?.security_analyzer,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<SdkSectionPage
|
||||
scope={scope}
|
||||
settingsSource="conversation_settings"
|
||||
settingsSource="agent_settings"
|
||||
sectionKeys={["verification"]}
|
||||
excludeKeys={VERIFICATION_SCHEMA_EXCLUDE_KEYS}
|
||||
prominenceOverrides={VERIFICATION_PROMINENCE_OVERRIDES}
|
||||
header={buildHeader}
|
||||
extraDirty={confirmationModeDirty || securityAnalyzerDirty}
|
||||
buildPayload={buildPayload}
|
||||
onSaveSuccess={() => {
|
||||
setConfirmationModeDirty(false);
|
||||
setSecurityAnalyzerDirty(false);
|
||||
}}
|
||||
testId={testId}
|
||||
/>
|
||||
);
|
||||
|
||||
50
frontend/src/types/v1/core/base/critic.ts
Normal file
50
frontend/src/types/v1/core/base/critic.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* A single feature detected by the critic (e.g., "Insufficient Testing").
|
||||
*/
|
||||
export interface CriticFeature {
|
||||
/** Internal feature name (e.g., "insufficient_testing") */
|
||||
name: string;
|
||||
/** Human-readable display name (e.g., "Insufficient Testing") */
|
||||
display_name: string;
|
||||
/** Probability of this feature being present (0-1) */
|
||||
probability: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorized features from the critic evaluation.
|
||||
*/
|
||||
export interface CriticCategorizedFeatures {
|
||||
/** Agent behavioral issues (e.g., insufficient testing, loop behavior) */
|
||||
agent_behavioral_issues?: CriticFeature[];
|
||||
/** Likely user follow-up patterns */
|
||||
user_followup_patterns?: CriticFeature[];
|
||||
/** Infrastructure-related issues */
|
||||
infrastructure_issues?: CriticFeature[];
|
||||
/** Other uncategorized metrics */
|
||||
other?: CriticFeature[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata from a critic evaluation, including categorized features
|
||||
* and event IDs for reproducibility.
|
||||
*/
|
||||
export interface CriticMetadata {
|
||||
categorized_features?: CriticCategorizedFeatures;
|
||||
event_ids?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a critic evaluation on an agent's actions.
|
||||
*
|
||||
* The critic predicts the probability that the agent has successfully
|
||||
* completed the task.
|
||||
*/
|
||||
export interface CriticResult {
|
||||
/** Predicted probability of success (0-1) */
|
||||
score: number;
|
||||
/** Optional message explaining the score */
|
||||
message: string | null;
|
||||
/** Optional metadata with categorized features and event IDs */
|
||||
metadata: CriticMetadata | null;
|
||||
}
|
||||
@@ -2,5 +2,6 @@
|
||||
export * from "./action";
|
||||
export * from "./base";
|
||||
export * from "./common";
|
||||
export * from "./critic";
|
||||
export * from "./event";
|
||||
export * from "./observation";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Action } from "../base/action";
|
||||
import { EventID, ToolCallID, SecurityRisk, TextContent } from "../base/common";
|
||||
import { CriticResult } from "../base/critic";
|
||||
import {
|
||||
BaseEvent,
|
||||
ChatCompletionMessageToolCall,
|
||||
@@ -59,6 +60,11 @@ export interface ActionEvent<T extends Action = Action> extends BaseEvent {
|
||||
*/
|
||||
security_risk: SecurityRisk;
|
||||
|
||||
/**
|
||||
* Optional critic evaluation of this action and preceding history.
|
||||
*/
|
||||
critic_result?: CriticResult | null;
|
||||
|
||||
/**
|
||||
* Optional LLM-generated summary used to label the tool call in the UI.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TextContent } from "../base/common";
|
||||
import { CriticResult } from "../base/critic";
|
||||
import { BaseEvent, Message } from "../base/event";
|
||||
|
||||
export interface MessageEvent extends BaseEvent {
|
||||
@@ -16,4 +17,9 @@ export interface MessageEvent extends BaseEvent {
|
||||
* List of content added by agent context
|
||||
*/
|
||||
extended_content: TextContent[];
|
||||
|
||||
/**
|
||||
* Optional critic evaluation of the agent's work at this point.
|
||||
*/
|
||||
critic_result?: CriticResult | null;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
access_token_hard_timeout: timedelta | None
|
||||
app_mode: str | None = None
|
||||
tavily_api_key: str | None = None
|
||||
critic_server_url: str | None = None
|
||||
critic_model_name: str | None = None
|
||||
critic_api_key: str | None = None
|
||||
|
||||
async def _get_sandbox_grouping_strategy(self) -> SandboxGroupingStrategy:
|
||||
"""Get the sandbox grouping strategy from user settings."""
|
||||
@@ -1407,6 +1410,18 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
# --- build AgentSettings and create agent ---------------------------
|
||||
from fastmcp.mcp_config import MCPConfig
|
||||
|
||||
# Apply deployment-level critic defaults when the user hasn't
|
||||
# explicitly configured them. This allows SaaS deployments to
|
||||
# route critic requests through the LiteLLM proxy.
|
||||
verification = user.agent_settings.verification
|
||||
critic_updates: dict[str, object] = {}
|
||||
if verification.critic_server_url is None and self.critic_server_url:
|
||||
critic_updates['critic_server_url'] = self.critic_server_url
|
||||
if verification.critic_model_name is None and self.critic_model_name:
|
||||
critic_updates['critic_model_name'] = self.critic_model_name
|
||||
if critic_updates:
|
||||
verification = verification.model_copy(update=critic_updates)
|
||||
|
||||
configured_agent_settings = user.agent_settings.model_copy(
|
||||
update={
|
||||
'llm': llm,
|
||||
@@ -1416,9 +1431,16 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
system_message_suffix=effective_suffix,
|
||||
secrets=secrets,
|
||||
),
|
||||
'verification': verification,
|
||||
}
|
||||
)
|
||||
agent = configured_agent_settings.create_agent()
|
||||
|
||||
# Override the critic API key with a deployment-level service key
|
||||
# so that critic requests are not subject to per-user budget limits.
|
||||
if agent.critic is not None and self.critic_api_key:
|
||||
agent.critic.api_key = SecretStr(self.critic_api_key)
|
||||
|
||||
agent = self._apply_server_agent_overrides(
|
||||
agent, agent_type, mcp_config, conversation_id, user.id
|
||||
)
|
||||
@@ -2177,4 +2199,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||
access_token_hard_timeout=access_token_hard_timeout,
|
||||
app_mode=app_mode,
|
||||
tavily_api_key=tavily_api_key,
|
||||
critic_server_url=config.critic_server_url,
|
||||
critic_model_name=config.critic_model_name,
|
||||
critic_api_key=config.critic_api_key,
|
||||
)
|
||||
|
||||
@@ -112,6 +112,39 @@ def get_openhands_provider_base_url() -> str | None:
|
||||
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or os.getenv('LLM_BASE_URL') or None
|
||||
|
||||
|
||||
def get_critic_server_url() -> str | None:
|
||||
"""Return the deployment-level critic server URL, if configured.
|
||||
|
||||
Priority:
|
||||
1. Explicit ``CRITIC_SERVER_URL`` env var.
|
||||
2. Derived from ``OPENHANDS_PROVIDER_BASE_URL`` (append ``/vllm``
|
||||
for the LiteLLM pass-through path).
|
||||
"""
|
||||
explicit = os.getenv('CRITIC_SERVER_URL')
|
||||
if explicit:
|
||||
return explicit
|
||||
provider_base = get_openhands_provider_base_url()
|
||||
if provider_base:
|
||||
return provider_base.rstrip('/') + '/vllm'
|
||||
return None
|
||||
|
||||
|
||||
def get_critic_model_name() -> str | None:
|
||||
"""Return the deployment-level critic model name, if configured."""
|
||||
return os.getenv('CRITIC_MODEL_NAME') or None
|
||||
|
||||
|
||||
def get_critic_api_key() -> str | None:
|
||||
"""Return the deployment-level critic API key, if configured.
|
||||
|
||||
When set, this key is injected into the critic client *after* agent
|
||||
creation, overriding the default behaviour of inheriting the user's
|
||||
LLM proxy key. This allows deployments to provide a service-level
|
||||
key that is not subject to per-user budget limits.
|
||||
"""
|
||||
return os.getenv('CRITIC_API_KEY') or None
|
||||
|
||||
|
||||
# The SDK auto-fills this URL as the default for openhands/ and litellm_proxy/
|
||||
# models. Deployments (e.g. staging) may use a different LLM proxy, configured
|
||||
# via OPENHANDS_PROVIDER_BASE_URL.
|
||||
@@ -182,6 +215,18 @@ class AppServerConfig(OpenHandsModel):
|
||||
default_factory=get_openhands_provider_base_url,
|
||||
description='Base URL for the OpenHands provider',
|
||||
)
|
||||
critic_server_url: str | None = Field(
|
||||
default_factory=get_critic_server_url,
|
||||
description='Deployment-level critic server URL.',
|
||||
)
|
||||
critic_model_name: str | None = Field(
|
||||
default_factory=get_critic_model_name,
|
||||
description='Deployment-level critic model name.',
|
||||
)
|
||||
critic_api_key: str | None = Field(
|
||||
default_factory=get_critic_api_key,
|
||||
description='Deployment-level critic API key (bypasses per-user budget).',
|
||||
)
|
||||
# Dependency Injection Injectors
|
||||
event: EventServiceInjector | None = None
|
||||
event_callback: EventCallbackServiceInjector | None = None
|
||||
|
||||
@@ -44,13 +44,13 @@ from openhands.app_server.user.specifiy_user_context import (
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext,
|
||||
)
|
||||
from openhands.app_server.user_auth.default_user_auth import DefaultUserAuth
|
||||
from openhands.app_server.user_auth.user_auth import (
|
||||
get_for_user as get_user_auth_for_user,
|
||||
)
|
||||
from openhands.sdk import ConversationExecutionStatus, Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth.default_user_auth import DefaultUserAuth
|
||||
from openhands.server.user_auth.user_auth import (
|
||||
get_for_user as get_user_auth_for_user,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
|
||||
event_service_dependency = depends_event_service()
|
||||
|
||||
@@ -30,13 +30,13 @@ from openhands.app_server.user.specifiy_user_context import (
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext,
|
||||
)
|
||||
from openhands.app_server.user_auth import (
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
mcp_server = FastMCP('mcp', mask_error_details=True)
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ from openhands.app_server.sandbox.sandbox_service import (
|
||||
)
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.app_server.user_auth.user_auth import (
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.server.user_auth.user_auth import (
|
||||
get_for_user as get_user_auth_for_user,
|
||||
)
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -22,13 +22,13 @@ from openhands.app_server.secrets.secrets_models import (
|
||||
)
|
||||
from openhands.app_server.secrets.secrets_store import SecretsStore
|
||||
from openhands.app_server.settings.settings_models import POSTProviderModel
|
||||
from openhands.app_server.user_auth import (
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.models import EditResponse
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets,
|
||||
get_secrets_store,
|
||||
)
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.models import EditResponse
|
||||
|
||||
# Create router with /api/v1/secrets prefix
|
||||
router = APIRouter(
|
||||
|
||||
@@ -12,6 +12,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.app_server.config import get_critic_model_name, get_critic_server_url
|
||||
from openhands.app_server.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderType,
|
||||
@@ -29,18 +30,18 @@ from openhands.app_server.settings.settings_models import (
|
||||
Settings,
|
||||
)
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.app_server.user_auth import (
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk.llm import LLM
|
||||
from openhands.sdk.settings import ConversationSettings
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
get_user_id,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk.llm import LLM
|
||||
from openhands.sdk.settings import ConversationSettings
|
||||
from openhands.server.shared import config
|
||||
from openhands.utils.llm import (
|
||||
get_provider_api_base,
|
||||
is_openhands_model,
|
||||
@@ -80,10 +81,43 @@ def _post_merge_llm_fixups(settings: Settings) -> None:
|
||||
)
|
||||
|
||||
|
||||
# NOTE: We use response_model=None for endpoints that return JSONResponse directly.
|
||||
# This is because FastAPI's response_model expects a Pydantic model, but we're returning
|
||||
# a response object directly. We document the possible responses using the 'responses'
|
||||
# parameter and maintain proper type annotations for mypy.
|
||||
def _get_critic_sdk_defaults() -> tuple[str, str]:
|
||||
"""Return (server_url, model_name) SDK built-in defaults."""
|
||||
from openhands.sdk.critic.impl.api.client import CriticClient
|
||||
|
||||
return (
|
||||
CriticClient.model_fields['server_url'].default,
|
||||
CriticClient.model_fields['model_name'].default,
|
||||
)
|
||||
|
||||
|
||||
def _fill_critic_defaults(verification: Any) -> Any:
|
||||
"""Return a copy of *verification* with effective critic defaults filled.
|
||||
|
||||
When the user hasn't overridden ``critic_server_url`` or
|
||||
``critic_model_name``, we fill them with:
|
||||
1. Deployment env var (``CRITIC_SERVER_URL`` / ``CRITIC_MODEL_NAME``),
|
||||
2. SDK built-in default from :class:`CriticClient`.
|
||||
|
||||
Returns a new object — does not mutate the original.
|
||||
"""
|
||||
if verification is None:
|
||||
return verification
|
||||
|
||||
sdk_url, sdk_model = _get_critic_sdk_defaults()
|
||||
updates: dict[str, object] = {}
|
||||
|
||||
if verification.critic_server_url is None:
|
||||
updates['critic_server_url'] = get_critic_server_url() or sdk_url
|
||||
|
||||
if verification.critic_model_name is None:
|
||||
updates['critic_model_name'] = get_critic_model_name() or sdk_model
|
||||
|
||||
if not updates:
|
||||
return verification
|
||||
return verification.model_copy(update=updates)
|
||||
|
||||
|
||||
@router.get(
|
||||
'',
|
||||
response_model=GETSettingsModel,
|
||||
@@ -167,6 +201,13 @@ async def load_settings(
|
||||
resp_llm.api_key = None
|
||||
settings_with_token_data.search_api_key = None
|
||||
settings_with_token_data.sandbox_api_key = None
|
||||
|
||||
# Fill effective critic defaults so the frontend shows what the
|
||||
# agent will actually use rather than empty fields.
|
||||
settings_with_token_data.agent_settings.verification = _fill_critic_defaults(
|
||||
settings_with_token_data.agent_settings.verification
|
||||
)
|
||||
|
||||
return settings_with_token_data
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid token: {e}')
|
||||
|
||||
@@ -15,8 +15,8 @@ from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext, UserContextInjector
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth, get_user_auth
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth, get_user_auth
|
||||
|
||||
USER_AUTH_ATTR = 'user_auth'
|
||||
|
||||
|
||||
@@ -10,18 +10,18 @@ def import_from(qual_name: str):
|
||||
|
||||
This function is a utility to dynamically import any Python value (class,
|
||||
function, variable) from its fully qualified name. For example,
|
||||
'openhands.app_server.user_auth.UserAuth' would import the UserAuth class from the
|
||||
openhands.app_server.user_auth module.
|
||||
'openhands.server.user_auth.UserAuth' would import the UserAuth class from the
|
||||
openhands.server.user_auth module.
|
||||
|
||||
Args:
|
||||
qual_name: A fully qualified name in the format 'module.submodule.name'
|
||||
e.g. 'openhands.app_server.user_auth.UserAuth'
|
||||
e.g. 'openhands.server.user_auth.UserAuth'
|
||||
|
||||
Returns:
|
||||
The imported value (class, function, or variable)
|
||||
|
||||
Example:
|
||||
>>> UserAuth = import_from('openhands.app_server.user_auth.UserAuth')
|
||||
>>> UserAuth = import_from('openhands.server.user_auth.UserAuth')
|
||||
>>> auth = UserAuth()
|
||||
"""
|
||||
parts = qual_name.split('.')
|
||||
|
||||
@@ -6,6 +6,7 @@ from openhands.core.config.arg_utils import (
|
||||
from openhands.core.config.config_utils import (
|
||||
OH_DEFAULT_AGENT,
|
||||
OH_MAX_ITERATIONS,
|
||||
get_field_info,
|
||||
)
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
@@ -44,6 +45,7 @@ __all__ = [
|
||||
'finalize_config',
|
||||
'get_agent_config_arg',
|
||||
'get_llm_config_arg',
|
||||
'get_field_info',
|
||||
'get_headless_parser',
|
||||
'get_evaluation_parser',
|
||||
'parse_arguments',
|
||||
|
||||
8
openhands/core/const/guide_url.py
Normal file
8
openhands/core/const/guide_url.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
TROUBLESHOOTING_URL = 'https://docs.all-hands.dev/usage/troubleshooting'
|
||||
218
openhands/core/exceptions.py
Normal file
218
openhands/core/exceptions.py
Normal file
@@ -0,0 +1,218 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# ============================================
|
||||
# Agent Exceptions
|
||||
# ============================================
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
"""Base class for all agent exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentAlreadyRegisteredError(AgentError):
|
||||
def __init__(self, name: str | None = None) -> None:
|
||||
if name is not None:
|
||||
message = f"Agent class already registered under '{name}'"
|
||||
else:
|
||||
message = 'Agent class already registered'
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AgentNotRegisteredError(AgentError):
|
||||
def __init__(self, name: str | None = None) -> None:
|
||||
if name is not None:
|
||||
message = f"No agent class registered under '{name}'"
|
||||
else:
|
||||
message = 'No agent class registered'
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AgentStuckInLoopError(AgentError):
|
||||
def __init__(self, message: str = 'Agent got stuck in a loop') -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ============================================
|
||||
# LLM Exceptions
|
||||
# ============================================
|
||||
|
||||
|
||||
# This exception gets sent back to the LLM
|
||||
# It might be malformed JSON
|
||||
class LLMMalformedActionError(Exception):
|
||||
def __init__(self, message: str = 'Malformed response') -> None:
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
# This exception gets sent back to the LLM
|
||||
# For some reason, the agent did not return an action
|
||||
class LLMNoActionError(Exception):
|
||||
def __init__(self, message: str = 'Agent must return an action') -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# This exception gets sent back to the LLM
|
||||
# The LLM output did not include an action, or the action was not the expected type
|
||||
class LLMResponseError(Exception):
|
||||
def __init__(
|
||||
self, message: str = 'Failed to retrieve action from LLM response'
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# This exception should be retried
|
||||
# Typically, after retry with a non-zero temperature, the LLM will return a response
|
||||
class LLMNoResponseError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'LLM did not return a response. This is only seen in Gemini models so far.',
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UserCancelledError(Exception):
|
||||
def __init__(self, message: str = 'User cancelled the request') -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class OperationCancelled(Exception):
|
||||
"""Exception raised when an operation is cancelled (e.g. by a keyboard interrupt)."""
|
||||
|
||||
def __init__(self, message: str = 'Operation was cancelled') -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class LLMContextWindowExceedError(RuntimeError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Conversation history longer than LLM context window limit. Consider turning on enable_history_truncation config to avoid this error',
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ============================================
|
||||
# LLM function calling Exceptions
|
||||
# ============================================
|
||||
|
||||
|
||||
class FunctionCallConversionError(Exception):
|
||||
"""Exception raised when FunctionCallingConverter failed to convert a non-function call message to a function call message.
|
||||
|
||||
This typically happens when there's a malformed message (e.g., missing <function=...> tags). But not due to LLM output.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class FunctionCallValidationError(Exception):
|
||||
"""Exception raised when FunctionCallingConverter failed to validate a function call message.
|
||||
|
||||
This typically happens when the LLM outputs unrecognized function call / parameter names / values.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class FunctionCallNotExistsError(Exception):
|
||||
"""Exception raised when an LLM call a tool that is not registered."""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ============================================
|
||||
# Agent Runtime Exceptions
|
||||
# ============================================
|
||||
|
||||
|
||||
class AgentRuntimeError(Exception):
|
||||
"""Base class for all agent runtime exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentRuntimeBuildError(AgentRuntimeError):
|
||||
"""Exception raised when an agent runtime build operation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentRuntimeTimeoutError(AgentRuntimeError):
|
||||
"""Exception raised when an agent runtime operation times out."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentRuntimeUnavailableError(AgentRuntimeError):
|
||||
"""Exception raised when an agent runtime is unavailable."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentRuntimeNotReadyError(AgentRuntimeUnavailableError):
|
||||
"""Exception raised when an agent runtime is not ready."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentRuntimeDisconnectedError(AgentRuntimeUnavailableError):
|
||||
"""Exception raised when an agent runtime is disconnected."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentRuntimeNotFoundError(AgentRuntimeUnavailableError):
|
||||
"""Exception raised when an agent runtime is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ============================================
|
||||
# Browser Exceptions
|
||||
# ============================================
|
||||
|
||||
|
||||
class BrowserInitException(Exception):
|
||||
def __init__(
|
||||
self, message: str = 'Failed to initialize browser environment'
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class BrowserUnavailableException(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
message: str = 'Browser environment is not available, please check if has been initialized',
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ============================================
|
||||
# Microagent Exceptions
|
||||
# ============================================
|
||||
|
||||
|
||||
class MicroagentError(Exception):
|
||||
"""Base exception for all microagent errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MicroagentValidationError(MicroagentError):
|
||||
"""Raised when there's a validation error in microagent metadata."""
|
||||
|
||||
def __init__(self, message: str = 'Microagent validation failed') -> None:
|
||||
super().__init__(message)
|
||||
165
openhands/core/message.py
Normal file
165
openhands/core/message.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from litellm import ChatCompletionMessageToolCall
|
||||
from pydantic import BaseModel, Field, model_serializer
|
||||
|
||||
|
||||
class ContentType(Enum):
|
||||
TEXT = 'text'
|
||||
IMAGE_URL = 'image_url'
|
||||
|
||||
|
||||
class Content(BaseModel):
|
||||
type: str
|
||||
cache_prompt: bool = False
|
||||
|
||||
@model_serializer(mode='plain')
|
||||
def serialize_model(
|
||||
self,
|
||||
) -> dict[str, str | dict[str, str]] | list[dict[str, str | dict[str, str]]]:
|
||||
raise NotImplementedError('Subclasses should implement this method.')
|
||||
|
||||
|
||||
class TextContent(Content):
|
||||
type: str = ContentType.TEXT.value
|
||||
text: str
|
||||
|
||||
@model_serializer(mode='plain')
|
||||
def serialize_model(self) -> dict[str, str | dict[str, str]]:
|
||||
data: dict[str, str | dict[str, str]] = {
|
||||
'type': self.type,
|
||||
'text': self.text,
|
||||
}
|
||||
if self.cache_prompt:
|
||||
data['cache_control'] = {'type': 'ephemeral'}
|
||||
return data
|
||||
|
||||
|
||||
class ImageContent(Content):
|
||||
type: str = ContentType.IMAGE_URL.value
|
||||
image_urls: list[str]
|
||||
|
||||
@model_serializer(mode='plain')
|
||||
def serialize_model(self) -> list[dict[str, str | dict[str, str]]]:
|
||||
images: list[dict[str, str | dict[str, str]]] = []
|
||||
for url in self.image_urls:
|
||||
images.append({'type': self.type, 'image_url': {'url': url}})
|
||||
if self.cache_prompt and images:
|
||||
images[-1]['cache_control'] = {'type': 'ephemeral'}
|
||||
return images
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
# NOTE: this is not the same as EventSource
|
||||
# These are the roles in the LLM's APIs
|
||||
role: Literal['user', 'system', 'assistant', 'tool']
|
||||
content: list[TextContent | ImageContent] = Field(default_factory=list)
|
||||
cache_enabled: bool = False
|
||||
vision_enabled: bool = False
|
||||
# function calling
|
||||
function_calling_enabled: bool = False
|
||||
# - tool calls (from LLM)
|
||||
tool_calls: list[ChatCompletionMessageToolCall] | None = None
|
||||
# - tool execution result (to LLM)
|
||||
tool_call_id: str | None = None
|
||||
name: str | None = None # name of the tool
|
||||
# force string serializer
|
||||
force_string_serializer: bool = False
|
||||
|
||||
@property
|
||||
def contains_image(self) -> bool:
|
||||
return any(isinstance(content, ImageContent) for content in self.content)
|
||||
|
||||
@model_serializer(mode='plain')
|
||||
def serialize_model(self) -> dict[str, Any]:
|
||||
# We need two kinds of serializations:
|
||||
# - into a single string: for providers that don't support list of content items (e.g. no vision, no tool calls)
|
||||
# - into a list of content items: the new APIs of providers with vision/prompt caching/tool calls
|
||||
# NOTE: remove this when litellm or providers support the new API
|
||||
if not self.force_string_serializer and (
|
||||
self.cache_enabled or self.vision_enabled or self.function_calling_enabled
|
||||
):
|
||||
return self._list_serializer()
|
||||
# some providers, like HF and Groq/llama, don't support a list here, but a single string
|
||||
return self._string_serializer()
|
||||
|
||||
def _string_serializer(self) -> dict[str, Any]:
|
||||
# convert content to a single string
|
||||
content = '\n'.join(
|
||||
item.text for item in self.content if isinstance(item, TextContent)
|
||||
)
|
||||
message_dict: dict[str, Any] = {'content': content, 'role': self.role}
|
||||
|
||||
# add tool call keys if we have a tool call or response
|
||||
return self._add_tool_call_keys(message_dict)
|
||||
|
||||
def _list_serializer(self) -> dict[str, Any]:
|
||||
content: list[dict[str, Any]] = []
|
||||
role_tool_with_prompt_caching = False
|
||||
for item in self.content:
|
||||
d = item.model_dump()
|
||||
# We have to remove cache_prompt for tool content and move it up to the message level
|
||||
# See discussion here for details: https://github.com/BerriAI/litellm/issues/6422#issuecomment-2438765472
|
||||
if self.role == 'tool' and item.cache_prompt:
|
||||
role_tool_with_prompt_caching = True
|
||||
if isinstance(item, TextContent):
|
||||
d.pop('cache_control', None)
|
||||
elif isinstance(item, ImageContent):
|
||||
# ImageContent.model_dump() always returns a list
|
||||
# We know d is a list of dicts for ImageContent
|
||||
if hasattr(d, '__iter__'):
|
||||
for d_item in d:
|
||||
if hasattr(d_item, 'pop'):
|
||||
d_item.pop('cache_control', None)
|
||||
|
||||
if isinstance(item, TextContent):
|
||||
content.append(d)
|
||||
elif isinstance(item, ImageContent) and self.vision_enabled:
|
||||
# ImageContent.model_dump() always returns a list
|
||||
# We know d is a list for ImageContent
|
||||
content.extend([d] if isinstance(d, dict) else d)
|
||||
|
||||
message_dict: dict[str, Any] = {'content': content, 'role': self.role}
|
||||
|
||||
if role_tool_with_prompt_caching:
|
||||
message_dict['cache_control'] = {'type': 'ephemeral'}
|
||||
|
||||
# add tool call keys if we have a tool call or response
|
||||
return self._add_tool_call_keys(message_dict)
|
||||
|
||||
def _add_tool_call_keys(self, message_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Add tool call keys if we have a tool call or response.
|
||||
|
||||
NOTE: this is necessary for both native and non-native tool calling
|
||||
"""
|
||||
# an assistant message calling a tool
|
||||
if self.tool_calls is not None:
|
||||
message_dict['tool_calls'] = [
|
||||
{
|
||||
'id': tool_call.id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': tool_call.function.name,
|
||||
'arguments': tool_call.function.arguments,
|
||||
},
|
||||
}
|
||||
for tool_call in self.tool_calls
|
||||
]
|
||||
|
||||
# an observation message with tool response
|
||||
if self.tool_call_id is not None:
|
||||
assert self.name is not None, (
|
||||
'name is required when tool_call_id is not None'
|
||||
)
|
||||
message_dict['tool_call_id'] = self.tool_call_id
|
||||
message_dict['name'] = self.name
|
||||
|
||||
return message_dict
|
||||
96
openhands/core/message_format.md
Normal file
96
openhands/core/message_format.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# OpenHands Message Format and litellm Integration
|
||||
|
||||
## Overview
|
||||
|
||||
OpenHands uses its own `Message` class (`openhands/core/message.py`) which provides rich content support while maintaining compatibility with litellm's message handling system.
|
||||
|
||||
## Class Structure
|
||||
|
||||
Our `Message` class (`openhands/core/message.py`):
|
||||
|
||||
```python
|
||||
class Message(BaseModel):
|
||||
role: Literal['user', 'system', 'assistant', 'tool']
|
||||
content: list[TextContent | ImageContent] = Field(default_factory=list)
|
||||
cache_enabled: bool = False
|
||||
vision_enabled: bool = False
|
||||
condensable: bool = True
|
||||
function_calling_enabled: bool = False
|
||||
tool_calls: list[ChatCompletionMessageToolCall] | None = None
|
||||
tool_call_id: str | None = None
|
||||
name: str | None = None
|
||||
event_id: int = -1
|
||||
```
|
||||
|
||||
litellm's `Message` class (`litellm/types/utils.py`):
|
||||
|
||||
```python
|
||||
class Message(OpenAIObject):
|
||||
content: str | None
|
||||
role: Literal["assistant", "user", "system", "tool", "function"]
|
||||
tool_calls: List[ChatCompletionMessageToolCall] | None
|
||||
function_call: FunctionCall | None
|
||||
audio: ChatCompletionAudioResponse | None = None
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Message Creation**: Our `Message` class is a Pydantic model that supports rich content (text and images) through its `content` field.
|
||||
|
||||
2. **Serialization**: The class uses Pydantic's `@model_serializer` to convert messages into dictionaries that litellm can understand. We have two serialization methods:
|
||||
|
||||
```python
|
||||
def _string_serializer(self) -> dict:
|
||||
# convert content to a single string
|
||||
content = '\n'.join(item.text for item in self.content if isinstance(item, TextContent))
|
||||
message_dict: dict = {'content': content, 'role': self.role}
|
||||
return self._add_tool_call_keys(message_dict)
|
||||
|
||||
def _list_serializer(self) -> dict:
|
||||
content: list[dict] = []
|
||||
for item in self.content:
|
||||
d = item.model_dump()
|
||||
if isinstance(item, TextContent):
|
||||
content.append(d)
|
||||
elif isinstance(item, ImageContent) and self.vision_enabled:
|
||||
content.extend(d)
|
||||
return {'content': content, 'role': self.role}
|
||||
```
|
||||
|
||||
The appropriate serializer is chosen based on the message's capabilities:
|
||||
|
||||
```python
|
||||
@model_serializer
|
||||
def serialize_model(self) -> dict:
|
||||
if self.cache_enabled or self.vision_enabled or self.function_calling_enabled:
|
||||
return self._list_serializer()
|
||||
return self._string_serializer()
|
||||
```
|
||||
|
||||
3. **Tool Call Handling**: Tool calls require special attention in serialization because:
|
||||
|
||||
- They need to work with litellm's API calls (which accept both dicts and objects)
|
||||
- They need to be properly serialized for token counting
|
||||
- They need to maintain compatibility with different LLM providers' formats
|
||||
|
||||
4. **litellm Integration**: When we pass our messages to `litellm.completion()`, litellm doesn't care about the message class type - it works with the dictionary representation. This works because:
|
||||
|
||||
- litellm's transformation code (e.g., `litellm/llms/anthropic/chat/transformation.py`) processes messages based on their structure, not their type
|
||||
- our serialization produces dictionaries that match litellm's expected format
|
||||
- litellm handles rich content by looking at the message structure, supporting both simple string content and lists of content items
|
||||
|
||||
5. **Provider-Specific Handling**: litellm then transforms these messages into provider-specific formats (e.g., Anthropic, OpenAI) through its transformation layers, which know how to handle both simple and rich content structures.
|
||||
|
||||
### Token Counting
|
||||
|
||||
To use litellm's token counter, we need to make sure that all message components (including tool calls) are properly serialized to dictionaries. This is because:
|
||||
|
||||
- litellm's token counter expects dictionary structures
|
||||
- Tool calls need to be included in the token count
|
||||
- Different providers may count tokens differently for structured content
|
||||
|
||||
## Note
|
||||
|
||||
- We don't need to inherit from litellm's `Message` class because litellm works with dictionary representations, not class types
|
||||
- Our rich content model is more sophisticated than litellm's basic string content, but litellm handles it correctly through its transformation layers
|
||||
- The compatibility is maintained through proper serialization rather than inheritance
|
||||
@@ -1,5 +1,9 @@
|
||||
from openhands.core.schema.action import ActionType
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.core.schema.observation import ObservationType
|
||||
|
||||
__all__ = [
|
||||
'ActionType',
|
||||
'ObservationType',
|
||||
'AgentState',
|
||||
]
|
||||
|
||||
109
openhands/core/schema/action.py
Normal file
109
openhands/core/schema/action.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ActionType(str, Enum):
|
||||
MESSAGE = 'message'
|
||||
"""Represents a message.
|
||||
"""
|
||||
|
||||
SYSTEM = 'system'
|
||||
"""Represents a system message.
|
||||
"""
|
||||
|
||||
START = 'start'
|
||||
"""Starts a new development task OR send chat from the user. Only sent by the client.
|
||||
"""
|
||||
|
||||
READ = 'read'
|
||||
"""Reads the content of a file.
|
||||
"""
|
||||
|
||||
WRITE = 'write'
|
||||
"""Writes the content to a file.
|
||||
"""
|
||||
|
||||
EDIT = 'edit'
|
||||
"""Edits a file by providing a draft.
|
||||
"""
|
||||
|
||||
RUN = 'run'
|
||||
"""Runs a command.
|
||||
"""
|
||||
|
||||
RUN_IPYTHON = 'run_ipython'
|
||||
"""Runs a IPython cell.
|
||||
"""
|
||||
|
||||
BROWSE = 'browse'
|
||||
"""Opens a web page.
|
||||
"""
|
||||
|
||||
BROWSE_INTERACTIVE = 'browse_interactive'
|
||||
"""Interact with the browser instance.
|
||||
"""
|
||||
|
||||
MCP = 'call_tool_mcp'
|
||||
"""Interact with the MCP server.
|
||||
"""
|
||||
|
||||
DELEGATE = 'delegate'
|
||||
"""Delegates a task to another agent.
|
||||
"""
|
||||
|
||||
THINK = 'think'
|
||||
"""Logs a thought.
|
||||
"""
|
||||
|
||||
FINISH = 'finish'
|
||||
"""If you're absolutely certain that you've completed your task and have tested your work,
|
||||
use the finish action to stop working.
|
||||
"""
|
||||
|
||||
REJECT = 'reject'
|
||||
"""If you're absolutely certain that you cannot complete the task with given requirements,
|
||||
use the reject action to stop working.
|
||||
"""
|
||||
|
||||
NULL = 'null'
|
||||
|
||||
PAUSE = 'pause'
|
||||
"""Pauses the task.
|
||||
"""
|
||||
|
||||
RESUME = 'resume'
|
||||
"""Resumes the task.
|
||||
"""
|
||||
|
||||
STOP = 'stop'
|
||||
"""Stops the task. Must send a start action to restart a new task.
|
||||
"""
|
||||
|
||||
CHANGE_AGENT_STATE = 'change_agent_state'
|
||||
|
||||
PUSH = 'push'
|
||||
"""Push a branch to github."""
|
||||
|
||||
SEND_PR = 'send_pr'
|
||||
"""Send a PR to github."""
|
||||
|
||||
RECALL = 'recall'
|
||||
"""Retrieves content from a user workspace, microagent, or other source."""
|
||||
|
||||
CONDENSATION = 'condensation'
|
||||
"""Condenses a list of events into a summary."""
|
||||
|
||||
CONDENSATION_REQUEST = 'condensation_request'
|
||||
"""Request for condensation of a list of events."""
|
||||
|
||||
TASK_TRACKING = 'task_tracking'
|
||||
"""Views or updates the task list for task management."""
|
||||
|
||||
LOOP_RECOVERY = 'loop_recovery'
|
||||
"""Recover dead loop."""
|
||||
14
openhands/core/schema/exit_reason.py
Normal file
14
openhands/core/schema/exit_reason.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ExitReason(Enum):
|
||||
INTENTIONAL = 'intentional'
|
||||
INTERRUPTED = 'interrupted'
|
||||
ERROR = 'error'
|
||||
70
openhands/core/schema/observation.py
Normal file
70
openhands/core/schema/observation.py
Normal file
@@ -0,0 +1,70 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ObservationType(str, Enum):
|
||||
READ = 'read'
|
||||
"""The content of a file
|
||||
"""
|
||||
|
||||
WRITE = 'write'
|
||||
|
||||
EDIT = 'edit'
|
||||
|
||||
BROWSE = 'browse'
|
||||
"""The HTML content of a URL
|
||||
"""
|
||||
|
||||
RUN = 'run'
|
||||
"""The output of a command
|
||||
"""
|
||||
|
||||
RUN_IPYTHON = 'run_ipython'
|
||||
"""Runs a IPython cell.
|
||||
"""
|
||||
|
||||
CHAT = 'chat'
|
||||
"""A message from the user
|
||||
"""
|
||||
|
||||
DELEGATE = 'delegate'
|
||||
"""The result of a task delegated to another agent
|
||||
"""
|
||||
|
||||
MESSAGE = 'message'
|
||||
|
||||
ERROR = 'error'
|
||||
|
||||
SUCCESS = 'success'
|
||||
|
||||
NULL = 'null'
|
||||
|
||||
THINK = 'think'
|
||||
|
||||
AGENT_STATE_CHANGED = 'agent_state_changed'
|
||||
|
||||
USER_REJECTED = 'user_rejected'
|
||||
|
||||
CONDENSE = 'condense'
|
||||
"""Result of a condensation operation."""
|
||||
|
||||
RECALL = 'recall'
|
||||
"""Result of a recall operation. This can be the workspace context, a microagent, or other types of information."""
|
||||
|
||||
MCP = 'mcp'
|
||||
"""Result of a MCP Server operation"""
|
||||
|
||||
DOWNLOAD = 'download'
|
||||
"""Result of downloading/opening a file via the browser"""
|
||||
|
||||
TASK_TRACKING = 'task_tracking'
|
||||
"""Result of a task tracking operation"""
|
||||
|
||||
LOOP_DETECTION = 'loop_detection'
|
||||
"""Results of a dead-loop detection"""
|
||||
99
openhands/core/setup.py
Normal file
99
openhands/core/setup.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.app_server.integrations.provider import (
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
)
|
||||
|
||||
|
||||
def get_provider_tokens():
|
||||
"""Retrieve provider tokens from environment variables and return them as a dictionary.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping ProviderType to ProviderToken if tokens are found, otherwise None.
|
||||
"""
|
||||
# Collect provider tokens from environment variables if available
|
||||
provider_tokens = {}
|
||||
if 'GITHUB_TOKEN' in os.environ:
|
||||
github_token = SecretStr(os.environ['GITHUB_TOKEN'])
|
||||
provider_tokens[ProviderType.GITHUB] = ProviderToken(token=github_token)
|
||||
|
||||
if 'GITLAB_TOKEN' in os.environ:
|
||||
gitlab_token = SecretStr(os.environ['GITLAB_TOKEN'])
|
||||
provider_tokens[ProviderType.GITLAB] = ProviderToken(token=gitlab_token)
|
||||
|
||||
if 'BITBUCKET_TOKEN' in os.environ:
|
||||
bitbucket_token = SecretStr(os.environ['BITBUCKET_TOKEN'])
|
||||
provider_tokens[ProviderType.BITBUCKET] = ProviderToken(token=bitbucket_token)
|
||||
|
||||
# Forgejo support (e.g., Codeberg or self-hosted Forgejo)
|
||||
if 'FORGEJO_TOKEN' in os.environ:
|
||||
forgejo_token = SecretStr(os.environ['FORGEJO_TOKEN'])
|
||||
# If a base URL is provided, extract the domain to use as host override
|
||||
forgejo_base_url = os.environ.get('FORGEJO_BASE_URL', '').strip()
|
||||
host: str | None = None
|
||||
if forgejo_base_url:
|
||||
# Normalize by stripping protocol and any path (e.g., /api/v1)
|
||||
url = forgejo_base_url
|
||||
if url.startswith(('http://', 'https://')):
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
host = parsed.netloc or None
|
||||
except Exception:
|
||||
pass
|
||||
if host is None:
|
||||
host = url.replace('https://', '').replace('http://', '')
|
||||
host = host.split('/')[0].strip('/') if host else None
|
||||
provider_tokens[ProviderType.FORGEJO] = ProviderToken(
|
||||
token=forgejo_token, host=host
|
||||
)
|
||||
|
||||
# Wrap provider tokens in Secrets if any tokens were found
|
||||
secret_store = (
|
||||
Secrets(provider_tokens=provider_tokens) if provider_tokens else None # type: ignore[arg-type]
|
||||
)
|
||||
return secret_store.provider_tokens if secret_store else None
|
||||
|
||||
|
||||
def generate_sid(config: OpenHandsConfig, session_name: str | None = None) -> str:
|
||||
"""Generate a session id based on the session name and the jwt secret.
|
||||
|
||||
The session ID is kept short to ensure Kubernetes resource names don't exceed
|
||||
the 63-character limit when prefixed with 'openhands-runtime-' (18 chars).
|
||||
Total length is limited to 32 characters to allow for suffixes like '-svc', '-pvc'.
|
||||
"""
|
||||
session_name = session_name or str(uuid.uuid4())
|
||||
jwt_secret = config.jwt_secret
|
||||
|
||||
hash_str = hashlib.sha256(f'{session_name}{jwt_secret}'.encode('utf-8')).hexdigest()
|
||||
|
||||
# Limit total session ID length to 32 characters for Kubernetes compatibility:
|
||||
# - 'openhands-runtime-' (18 chars) + session_id (32 chars) = 50 chars
|
||||
# - Leaves 13 chars for suffixes like '-svc' (4), '-pvc' (4), '-ingress-code' (13)
|
||||
if len(session_name) > 16:
|
||||
# If session_name is too long, use first 16 chars + 15-char hash for better readability
|
||||
# e.g., "vscode-extension" -> "vscode-extensio-{15-char-hash}"
|
||||
session_id = f'{session_name[:16]}-{hash_str[:15]}'
|
||||
else:
|
||||
# If session_name is short enough, use it + remaining space for hash
|
||||
remaining_chars = 32 - len(session_name) - 1 # -1 for the dash
|
||||
session_id = f'{session_name}-{hash_str[:remaining_chars]}'
|
||||
|
||||
return session_id[:32] # Ensure we never exceed 32 characters
|
||||
@@ -1,28 +1,182 @@
|
||||
# OpenHands Legacy (V0) Server
|
||||
# OpenHands Server
|
||||
|
||||
> **IMPORTANT**: This is the legacy V0 web server, deprecated since version 1.0.0.
|
||||
> The V1 application server lives under `openhands/app_server/`.
|
||||
This is a WebSocket server that executes tasks using an agent.
|
||||
|
||||
This package provides the V0 ASGI entry point (`listen.py`) that is still used by
|
||||
the Makefile and the container `CMD`. It assembles the FastAPI app from
|
||||
`openhands.server.app`, layers on middleware, Socket.IO, and (optionally) serves
|
||||
the frontend static build.
|
||||
## Recommended Prerequisites
|
||||
|
||||
## Key modules
|
||||
- [Initialize the frontend code](../../frontend/README.md)
|
||||
- Install Python 3.12 (`brew install python` for those using homebrew)
|
||||
- Install pipx: (`brew install pipx` followed by `pipx ensurepath`)
|
||||
- Install poetry: (`pipx install poetry`)
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `app.py` | Creates the FastAPI application, mounts MCP and V1 routers |
|
||||
| `listen.py` | Adds middleware (CORS, caching, rate-limiting), mounts SPA static files, wraps in Socket.IO ASGI |
|
||||
| `listen_socket.py` | Re-exports the `sio` Socket.IO server for backward compatibility |
|
||||
| `shared.py` | Module-level singletons (config, server config, Socket.IO, store implementations) |
|
||||
| `middleware.py` | CORS, cache-control, and rate-limiting middleware |
|
||||
| `static.py` | `SPAStaticFiles` — serves the frontend with SPA fallback |
|
||||
| `types.py` | Shared types (`AppMode`, `ServerConfigInterface`, error classes) |
|
||||
| `config/server_config.py` | OSS `ServerConfig` implementation and loader |
|
||||
## Install
|
||||
|
||||
## Starting the server
|
||||
First build a distribution of the frontend code (From the project root directory):
|
||||
|
||||
```sh
|
||||
uvicorn openhands.server.listen:app --host 0.0.0.0 --port 3000
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
Next run `poetry shell` (So you don't have to repeat `poetry run`)
|
||||
|
||||
## Start the Server
|
||||
|
||||
```sh
|
||||
uvicorn openhands.server.listen:app --reload --port 3000
|
||||
```
|
||||
|
||||
## Test the Server
|
||||
|
||||
You can use [`websocat`](https://github.com/vi/websocat) to test the server.
|
||||
|
||||
```sh
|
||||
websocat ws://127.0.0.1:3000/ws
|
||||
{"action": "start", "args": {"task": "write a bash script that prints hello"}}
|
||||
```
|
||||
|
||||
## Supported Environment Variables
|
||||
|
||||
```sh
|
||||
LLM_API_KEY=sk-... # Your Anthropic API Key
|
||||
LLM_MODEL=claude-3-5-sonnet-20241022 # Default model for the agent to use
|
||||
SANDBOX_VOLUMES=/path/to/your/workspace:/workspace:rw # Mount paths in format host_path:container_path:mode
|
||||
```
|
||||
|
||||
## API Schema
|
||||
|
||||
There are two types of messages that can be sent to, or received from, the server:
|
||||
|
||||
* Actions
|
||||
* Observations
|
||||
|
||||
### Actions
|
||||
|
||||
An action has three parts:
|
||||
|
||||
* `action`: The action to be taken
|
||||
* `args`: The arguments for the action
|
||||
* `message`: A friendly message that can be put in the chat log
|
||||
|
||||
There are several kinds of actions. Their arguments are listed below.
|
||||
This list may grow over time.
|
||||
|
||||
* `initialize` - initializes the agent. Only sent by client.
|
||||
* `model` - the name of the model to use
|
||||
* `directory` - the path to the workspace
|
||||
* `agent_cls` - the class of the agent to use
|
||||
* `start` - starts a new development task. Only sent by the client.
|
||||
* `task` - the task to start
|
||||
* `read` - reads the content of a file.
|
||||
* `path` - the path of the file to read
|
||||
* `write` - writes the content to a file.
|
||||
* `path` - the path of the file to write
|
||||
* `content` - the content to write to the file
|
||||
* `run` - runs a command.
|
||||
* `command` - the command to run
|
||||
* `browse` - opens a web page.
|
||||
* `url` - the URL to open
|
||||
* `think` - Allows the agent to make a plan, set a goal, or record thoughts
|
||||
* `thought` - the thought to record
|
||||
* `finish` - agent signals that the task is completed
|
||||
|
||||
### Observations
|
||||
|
||||
An observation has four parts:
|
||||
|
||||
* `observation`: The observation type
|
||||
* `content`: A string representing the observed data
|
||||
* `extras`: additional structured data
|
||||
* `message`: A friendly message that can be put in the chat log
|
||||
|
||||
There are several kinds of observations. Their extras are listed below.
|
||||
This list may grow over time.
|
||||
|
||||
* `read` - the content of a file
|
||||
* `path` - the path of the file read
|
||||
* `browse` - the HTML content of a url
|
||||
* `url` - the URL opened
|
||||
* `run` - the output of a command
|
||||
* `command` - the command run
|
||||
* `exit_code` - the exit code of the command
|
||||
* `chat` - a message from the user
|
||||
|
||||
## Server Components
|
||||
|
||||
The following section describes the server-side components of the OpenHands project.
|
||||
|
||||
### 1. session/session.py
|
||||
|
||||
The `session.py` file defines the `Session` class, which represents a WebSocket session with a client. Key features include:
|
||||
|
||||
- Handling WebSocket connections and disconnections
|
||||
- Initializing and managing the agent session
|
||||
- Dispatching events between the client and the agent
|
||||
- Sending messages and errors to the client
|
||||
|
||||
### 2. session/agent_session.py
|
||||
|
||||
The `agent_session.py` file contains the `AgentSession` class, which manages the lifecycle of an agent within a session. Key features include:
|
||||
|
||||
- Creating and managing the runtime environment
|
||||
- Initializing the agent controller
|
||||
- Handling security analysis
|
||||
- Managing the event stream
|
||||
|
||||
### 3. session/conversation_manager/conversation_manager.py
|
||||
|
||||
The `conversation_manager.py` file defines the `ConversationManager` class, which is responsible for managing multiple client conversations. Key features include:
|
||||
|
||||
- Adding and restarting conversations
|
||||
- Sending messages to specific conversations
|
||||
- Cleaning up inactive conversations
|
||||
|
||||
### 4. listen.py
|
||||
|
||||
The `listen.py` file is the main server file that sets up the FastAPI application and defines various API endpoints. Key features include:
|
||||
|
||||
- Setting up CORS middleware
|
||||
- Handling WebSocket connections
|
||||
- Managing file uploads
|
||||
- Providing API endpoints for agent interactions, file operations, and security analysis
|
||||
- Serving static files for the frontend
|
||||
|
||||
## Workflow Description
|
||||
|
||||
1. **Server Initialization**:
|
||||
- The FastAPI application is created and configured in `listen.py`.
|
||||
- CORS middleware and static file serving are set up.
|
||||
- The `ConversationManager` is initialized.
|
||||
|
||||
2. **Client Connection**:
|
||||
- When a client connects via WebSocket, a new `Session` is created or an existing one is restarted.
|
||||
- The `Session` initializes an `AgentSession`, which sets up the runtime environment and agent controller.
|
||||
|
||||
3. **Agent Initialization**:
|
||||
- The client sends an initialization request.
|
||||
- The server creates and configures the agent based on the provided parameters.
|
||||
- The runtime environment is set up, and the agent controller is initialized.
|
||||
|
||||
4. **Event Handling**:
|
||||
- The `Session` manages the event stream between the client and the agent.
|
||||
- Events from the client are dispatched to the agent.
|
||||
- Observations from the agent are sent back to the client.
|
||||
|
||||
5. **File Operations**:
|
||||
- The server handles file uploads, ensuring they meet size and type restrictions.
|
||||
- File read and write operations are performed through the runtime environment.
|
||||
|
||||
6. **Security Analysis**:
|
||||
- If configured, a security analyzer is initialized for each session.
|
||||
- Security-related API requests are forwarded to the security analyzer.
|
||||
|
||||
7. **Session Management**:
|
||||
- The `ConversationManager` periodically cleans up inactive sessions.
|
||||
- It also handles sending messages to specific sessions when needed.
|
||||
|
||||
8. **API Endpoints**:
|
||||
- Various API endpoints are provided for agent interactions, file operations, and retrieving configuration defaults.
|
||||
|
||||
This server architecture allows for managing multiple client sessions, each with its own agent instance, runtime environment, and security analyzer. The event-driven design facilitates real-time communication between clients and agents, while the modular structure allows for easy extension and maintenance of different components.
|
||||
|
||||
@@ -28,7 +28,7 @@ class ServerConfig(ServerConfigInterface):
|
||||
'openhands.app_server.secrets.file_secrets_store.FileSecretsStore'
|
||||
)
|
||||
user_auth_class: str = (
|
||||
'openhands.app_server.user_auth.default_user_auth.DefaultUserAuth'
|
||||
'openhands.server.user_auth.default_user_auth.DefaultUserAuth'
|
||||
)
|
||||
enable_v1: bool = os.getenv('ENABLE_V1') != '0'
|
||||
|
||||
|
||||
11
openhands/server/constants.py
Normal file
11
openhands/server/constants.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
"""Server constants."""
|
||||
|
||||
ROOM_KEY = 'room:{sid}'
|
||||
147
openhands/server/file_config.py
Normal file
147
openhands/server/file_config.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
import os
|
||||
import re
|
||||
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import config as shared_config
|
||||
|
||||
FILES_TO_IGNORE = [
|
||||
'.git/',
|
||||
'.DS_Store',
|
||||
'node_modules/',
|
||||
'__pycache__/',
|
||||
'lost+found/',
|
||||
'.vscode/',
|
||||
]
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""Sanitize the filename to prevent directory traversal"""
|
||||
# Remove any directory components
|
||||
filename = os.path.basename(filename)
|
||||
# Remove any non-alphanumeric characters except for .-_
|
||||
filename = re.sub(r'[^\w\-_\.]', '', filename)
|
||||
# Limit the filename length
|
||||
max_length = 255
|
||||
if len(filename) > max_length:
|
||||
name, ext = os.path.splitext(filename)
|
||||
filename = name[: max_length - len(ext)] + ext
|
||||
return filename
|
||||
|
||||
|
||||
def load_file_upload_config(
|
||||
config: OpenHandsConfig = shared_config,
|
||||
) -> tuple[int, bool, list[str]]:
|
||||
"""Load file upload configuration from the config object.
|
||||
|
||||
This function retrieves the file upload settings from the global config object.
|
||||
It handles the following settings:
|
||||
- Maximum file size for uploads
|
||||
- Whether to restrict file types
|
||||
- List of allowed file extensions
|
||||
|
||||
It also performs sanity checks on the values to ensure they are valid and safe.
|
||||
|
||||
Returns:
|
||||
tuple: A tuple containing:
|
||||
- max_file_size_mb (int): Maximum file size in MB. 0 means no limit.
|
||||
- restrict_file_types (bool): Whether file type restrictions are enabled.
|
||||
- allowed_extensions (set): Set of allowed file extensions.
|
||||
"""
|
||||
# Retrieve values from config
|
||||
max_file_size_mb = config.file_uploads_max_file_size_mb
|
||||
restrict_file_types = config.file_uploads_restrict_file_types
|
||||
allowed_extensions = config.file_uploads_allowed_extensions
|
||||
|
||||
# Sanity check for max_file_size_mb
|
||||
if not isinstance(max_file_size_mb, int) or max_file_size_mb < 0:
|
||||
logger.warning(
|
||||
f'Invalid max_file_size_mb: {max_file_size_mb}. Setting to 0 (no limit).'
|
||||
)
|
||||
max_file_size_mb = 0
|
||||
|
||||
# Sanity check for allowed_extensions
|
||||
if not isinstance(allowed_extensions, (list, set)) or not allowed_extensions:
|
||||
logger.warning(
|
||||
f'Invalid allowed_extensions: {allowed_extensions}. Setting to [".*"].'
|
||||
)
|
||||
allowed_extensions = ['.*']
|
||||
else:
|
||||
# Ensure all extensions start with a dot and are lowercase
|
||||
allowed_extensions = [
|
||||
ext.lower() if ext.startswith('.') else f'.{ext.lower()}'
|
||||
for ext in allowed_extensions
|
||||
]
|
||||
|
||||
# If restrictions are disabled, allow all
|
||||
if not restrict_file_types:
|
||||
allowed_extensions = ['.*']
|
||||
|
||||
logger.debug(
|
||||
f'File upload config: max_size={max_file_size_mb}MB, '
|
||||
f'restrict_types={restrict_file_types}, '
|
||||
f'allowed_extensions={allowed_extensions}'
|
||||
)
|
||||
|
||||
return max_file_size_mb, restrict_file_types, allowed_extensions
|
||||
|
||||
|
||||
# Load configuration
|
||||
MAX_FILE_SIZE_MB, RESTRICT_FILE_TYPES, ALLOWED_EXTENSIONS = load_file_upload_config()
|
||||
|
||||
|
||||
def is_extension_allowed(filename: str) -> bool:
|
||||
"""Check if the file extension is allowed based on the current configuration.
|
||||
|
||||
This function supports wildcards and files without extensions.
|
||||
The check is case-insensitive for extensions.
|
||||
|
||||
Args:
|
||||
filename (str): The name of the file to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the file extension is allowed, False otherwise.
|
||||
"""
|
||||
if not RESTRICT_FILE_TYPES:
|
||||
return True
|
||||
|
||||
file_ext = os.path.splitext(filename)[1].lower() # Convert to lowercase
|
||||
return (
|
||||
'.*' in ALLOWED_EXTENSIONS
|
||||
or file_ext in (ext.lower() for ext in ALLOWED_EXTENSIONS)
|
||||
or (file_ext == '' and '.' in ALLOWED_EXTENSIONS)
|
||||
)
|
||||
|
||||
|
||||
def get_unique_filename(filename: str, folder_path: str) -> str:
|
||||
"""Returns unique filename on given folder_path. By checking if the given
|
||||
filename exists. If it doesn't, filename is simply returned.
|
||||
Otherwise, it append copy(#number) until the filename is unique.
|
||||
|
||||
Args:
|
||||
filename (str): The name of the file to check.
|
||||
folder_path (str): directory path in which file name check is performed.
|
||||
|
||||
Returns:
|
||||
string: unique filename.
|
||||
"""
|
||||
name, ext = os.path.splitext(filename)
|
||||
filename_candidate = filename
|
||||
copy_index = 0
|
||||
|
||||
while os.path.exists(os.path.join(folder_path, filename_candidate)):
|
||||
if copy_index == 0:
|
||||
filename_candidate = f'{name} copy{ext}'
|
||||
else:
|
||||
filename_candidate = f'{name} copy({copy_index}){ext}'
|
||||
copy_index += 1
|
||||
|
||||
return filename_candidate
|
||||
27
openhands/server/services/conversation_service.py
Normal file
27
openhands/server/services/conversation_service.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
from types import MappingProxyType
|
||||
|
||||
from openhands.app_server.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderToken,
|
||||
)
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
def create_provider_tokens_object(
|
||||
providers_set: list[ProviderType],
|
||||
) -> PROVIDER_TOKEN_TYPE:
|
||||
"""Create provider tokens object for the given providers."""
|
||||
provider_information: dict[ProviderType, ProviderToken] = {}
|
||||
|
||||
for provider in providers_set:
|
||||
provider_information[provider] = ProviderToken(token=None, user_id=None)
|
||||
|
||||
return MappingProxyType(provider_information)
|
||||
@@ -11,6 +11,8 @@ import os
|
||||
import socketio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from openhands.app_server.file_store import get_file_store
|
||||
from openhands.app_server.file_store.files import FileStore
|
||||
from openhands.app_server.secrets.secrets_store import SecretsStore
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.core.config import load_openhands_config
|
||||
@@ -27,6 +29,10 @@ assert isinstance(server_config_interface, ServerConfig), (
|
||||
'Loaded server config interface is not a ServerConfig, despite this being assumed'
|
||||
)
|
||||
server_config: ServerConfig = server_config_interface
|
||||
file_store: FileStore = get_file_store(
|
||||
file_store_type=config.file_store,
|
||||
file_store_path=config.file_store_path,
|
||||
)
|
||||
|
||||
client_manager = None
|
||||
redis_host = os.environ.get('REDIS_HOST')
|
||||
|
||||
@@ -8,15 +8,30 @@
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar, Protocol
|
||||
|
||||
|
||||
class AppMode(Enum):
|
||||
OPENHANDS = 'oss'
|
||||
SAAS = 'saas'
|
||||
|
||||
# Backwards-compatible alias (deprecated): prefer AppMode.OPENHANDS
|
||||
OSS = 'oss'
|
||||
|
||||
|
||||
class SessionMiddlewareInterface(Protocol):
|
||||
"""Protocol for session middleware classes."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ServerConfigInterface(ABC):
|
||||
CONFIG_PATH: ClassVar[str | None]
|
||||
APP_MODE: ClassVar[AppMode]
|
||||
POSTHOG_CLIENT_KEY: ClassVar[str]
|
||||
GITHUB_CLIENT_ID: ClassVar[str]
|
||||
ATTACH_SESSION_MIDDLEWARE_PATH: ClassVar[str]
|
||||
|
||||
@abstractmethod
|
||||
def verify_config(self) -> None:
|
||||
"""Verify configuration settings."""
|
||||
|
||||
@@ -7,7 +7,7 @@ from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.app_server.secrets.secrets_store import SecretsStore
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.app_server.user_auth.user_auth import AuthType, get_user_auth
|
||||
from openhands.server.user_auth.user_auth import AuthType, get_user_auth
|
||||
|
||||
|
||||
async def get_provider_tokens(request: Request) -> PROVIDER_TOKEN_TYPE | None:
|
||||
@@ -16,8 +16,8 @@ from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.app_server.secrets.secrets_store import SecretsStore
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server import shared
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -9,18 +9,18 @@ def import_from(qual_name: str):
|
||||
"""Import a value from its fully qualified name.
|
||||
|
||||
This function is a utility to dynamically import any Python value (class, function, variable)
|
||||
from its fully qualified name. For example, 'openhands.app_server.user_auth.UserAuth' would
|
||||
import the UserAuth class from the openhands.app_server.user_auth module.
|
||||
from its fully qualified name. For example, 'openhands.server.user_auth.UserAuth' would
|
||||
import the UserAuth class from the openhands.server.user_auth module.
|
||||
|
||||
Args:
|
||||
qual_name: A fully qualified name in the format 'module.submodule.name'
|
||||
e.g. 'openhands.app_server.user_auth.UserAuth'
|
||||
e.g. 'openhands.server.user_auth.UserAuth'
|
||||
|
||||
Returns:
|
||||
The imported value (class, function, or variable)
|
||||
|
||||
Example:
|
||||
>>> UserAuth = import_from('openhands.app_server.user_auth.UserAuth')
|
||||
>>> UserAuth = import_from('openhands.server.user_auth.UserAuth')
|
||||
>>> auth = UserAuth()
|
||||
"""
|
||||
parts = qual_name.split('.')
|
||||
|
||||
@@ -25,10 +25,10 @@ from openhands.app_server.settings.llm_profiles import MAX_PROFILES_PER_USER
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.app_server.settings.settings_router import _user_profile_locks
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.sdk.llm import LLM
|
||||
from openhands.sdk.settings import AgentSettings
|
||||
from openhands.server.app import app
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -101,7 +101,7 @@ def test_client(settings_store):
|
||||
),
|
||||
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
|
||||
patch(
|
||||
'openhands.app_server.user_auth.user_auth.UserAuth.get_instance',
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=auth,
|
||||
),
|
||||
patch(
|
||||
@@ -149,7 +149,7 @@ def _client_for_user(user_id: str, store: FileSettingsStore):
|
||||
),
|
||||
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
|
||||
patch(
|
||||
'openhands.app_server.user_auth.user_auth.UserAuth.get_instance',
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=auth,
|
||||
),
|
||||
patch(
|
||||
|
||||
@@ -14,7 +14,6 @@ from openhands.app_server.secrets.secrets_store import SecretsStore
|
||||
from openhands.app_server.settings.file_settings_store import FileSettingsStore
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.sdk.llm import LLM
|
||||
from openhands.sdk.settings import (
|
||||
AgentSettings,
|
||||
@@ -22,6 +21,7 @@ from openhands.sdk.settings import (
|
||||
VerificationSettings,
|
||||
)
|
||||
from openhands.server.app import app
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
_EXPOSE = {'expose_secrets': True}
|
||||
|
||||
@@ -99,7 +99,7 @@ def test_client():
|
||||
),
|
||||
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
|
||||
patch(
|
||||
'openhands.app_server.user_auth.user_auth.UserAuth.get_instance',
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=MockUserAuth(),
|
||||
),
|
||||
patch(
|
||||
@@ -324,3 +324,116 @@ async def test_disabled_skills_persistence(test_client):
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['disabled_skills'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critic_deployment_defaults_populated_in_response(test_client):
|
||||
"""Deployment-level critic defaults should appear in the GET response
|
||||
so the frontend shows the effective values instead of empty fields."""
|
||||
response = test_client.post(
|
||||
'/api/v1/settings',
|
||||
json=_dump_update(
|
||||
Settings(
|
||||
agent_settings=AgentSettings(
|
||||
llm=LLM(model='test-model'),
|
||||
verification=VerificationSettings(critic_enabled=True),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'OPENHANDS_PROVIDER_BASE_URL': 'https://llm-proxy.example.com',
|
||||
'CRITIC_MODEL_NAME': 'my-critic-model',
|
||||
},
|
||||
):
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
verification = response.json()['agent_settings']['verification']
|
||||
assert verification['critic_enabled'] is True
|
||||
assert verification['critic_server_url'] == (
|
||||
'https://llm-proxy.example.com/vllm'
|
||||
)
|
||||
assert verification['critic_model_name'] == 'my-critic-model'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critic_user_override_not_clobbered(test_client):
|
||||
"""When the user explicitly sets critic_server_url, the deployment
|
||||
default must NOT overwrite it."""
|
||||
response = test_client.post(
|
||||
'/api/v1/settings',
|
||||
json=_dump_update(
|
||||
Settings(
|
||||
agent_settings=AgentSettings(
|
||||
llm=LLM(model='test-model'),
|
||||
verification=VerificationSettings(
|
||||
critic_enabled=True,
|
||||
critic_server_url='https://my-custom-critic.example.com',
|
||||
critic_model_name='custom-model',
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
'OPENHANDS_PROVIDER_BASE_URL': 'https://llm-proxy.example.com',
|
||||
'CRITIC_MODEL_NAME': 'deployment-model',
|
||||
},
|
||||
):
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
verification = response.json()['agent_settings']['verification']
|
||||
assert (
|
||||
verification['critic_server_url'] == 'https://my-custom-critic.example.com'
|
||||
)
|
||||
assert verification['critic_model_name'] == 'custom-model'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critic_sdk_defaults_used_when_no_env_vars(test_client):
|
||||
"""When no deployment env vars are set, SDK CriticClient defaults
|
||||
should be used as the fallback for critic_server_url and critic_model_name."""
|
||||
from openhands.sdk.critic.impl.api.client import CriticClient
|
||||
|
||||
response = test_client.post(
|
||||
'/api/v1/settings',
|
||||
json=_dump_update(
|
||||
Settings(
|
||||
agent_settings=AgentSettings(
|
||||
llm=LLM(model='test-model'),
|
||||
verification=VerificationSettings(critic_enabled=True),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{},
|
||||
clear=False,
|
||||
):
|
||||
os.environ.pop('OPENHANDS_PROVIDER_BASE_URL', None)
|
||||
os.environ.pop('LLM_BASE_URL', None)
|
||||
os.environ.pop('CRITIC_SERVER_URL', None)
|
||||
os.environ.pop('CRITIC_MODEL_NAME', None)
|
||||
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
verification = response.json()['agent_settings']['verification']
|
||||
assert (
|
||||
verification['critic_server_url']
|
||||
== CriticClient.model_fields['server_url'].default
|
||||
)
|
||||
assert (
|
||||
verification['critic_model_name']
|
||||
== CriticClient.model_fields['model_name'].default
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user