From d591b140c8039ceecb589e4d3e9cf67881d16bc1 Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Mon, 16 Mar 2026 05:19:31 -0600 Subject: [PATCH] feat: Add configurable sandbox reuse with grouping strategies (#11922) Co-authored-by: openhands --- .../100_add_sandbox_grouping_strategy.py | 33 ++++ enterprise/storage/org.py | 1 + enterprise/storage/saas_settings_store.py | 3 + enterprise/storage/user.py | 1 + enterprise/storage/user_settings.py | 1 + frontend/__tests__/utils/get-git-path.test.ts | 22 ++- frontend/src/hooks/query/use-settings.ts | 3 + .../query/use-unified-get-git-changes.ts | 2 +- .../src/hooks/query/use-unified-git-diff.ts | 2 +- frontend/src/i18n/declaration.ts | 6 + frontend/src/i18n/translation.json | 96 +++++++++ frontend/src/routes/app-settings.tsx | 50 +++++ frontend/src/services/settings.ts | 1 + frontend/src/types/settings.ts | 12 ++ frontend/src/utils/feature-flags.ts | 2 + frontend/src/utils/get-git-path.ts | 5 +- .../app_conversation_models.py | 4 + .../live_status_app_conversation_service.py | 185 ++++++++++++++++-- .../server/routes/manage_conversations.py | 34 +++- openhands/storage/data_models/settings.py | 18 ++ ...st_live_status_app_conversation_service.py | 131 +++++++++++-- .../server/data_models/test_conversation.py | 3 + 22 files changed, 569 insertions(+), 46 deletions(-) create mode 100644 enterprise/migrations/versions/100_add_sandbox_grouping_strategy.py diff --git a/enterprise/migrations/versions/100_add_sandbox_grouping_strategy.py b/enterprise/migrations/versions/100_add_sandbox_grouping_strategy.py new file mode 100644 index 0000000000..e58f6fbcf6 --- /dev/null +++ b/enterprise/migrations/versions/100_add_sandbox_grouping_strategy.py @@ -0,0 +1,33 @@ +"""Add sandbox_grouping_strategy column to user, org, and user_settings tables. + +Revision ID: 100 +Revises: 099 +Create Date: 2025-03-12 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '100' +down_revision = '099' + + +def upgrade() -> None: + op.add_column( + 'user', + sa.Column('sandbox_grouping_strategy', sa.String, nullable=True), + ) + op.add_column( + 'org', + sa.Column('sandbox_grouping_strategy', sa.String, nullable=True), + ) + op.add_column( + 'user_settings', + sa.Column('sandbox_grouping_strategy', sa.String, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('user_settings', 'sandbox_grouping_strategy') + op.drop_column('org', 'sandbox_grouping_strategy') + op.drop_column('user', 'sandbox_grouping_strategy') diff --git a/enterprise/storage/org.py b/enterprise/storage/org.py index b0ec98b0a2..3b0b898fd1 100644 --- a/enterprise/storage/org.py +++ b/enterprise/storage/org.py @@ -47,6 +47,7 @@ class Org(Base): # type: ignore conversation_expiration = Column(Integer, nullable=True) condenser_max_size = Column(Integer, nullable=True) byor_export_enabled = Column(Boolean, nullable=False, default=False) + sandbox_grouping_strategy = Column(String, nullable=True) # Relationships org_members = relationship('OrgMember', back_populates='org') diff --git a/enterprise/storage/saas_settings_store.py b/enterprise/storage/saas_settings_store.py index d062ff6e7e..b2fbdac2bd 100644 --- a/enterprise/storage/saas_settings_store.py +++ b/enterprise/storage/saas_settings_store.py @@ -117,6 +117,9 @@ class SaasSettingsStore(SettingsStore): kwargs['llm_base_url'] = org_member.llm_base_url if org.v1_enabled is None: kwargs['v1_enabled'] = True + # Apply default if sandbox_grouping_strategy is None in the database + if kwargs.get('sandbox_grouping_strategy') is None: + kwargs.pop('sandbox_grouping_strategy', None) settings = Settings(**kwargs) return settings diff --git a/enterprise/storage/user.py b/enterprise/storage/user.py index adedf85366..2df86a7039 100644 --- a/enterprise/storage/user.py +++ b/enterprise/storage/user.py @@ -33,6 +33,7 @@ class User(Base): # type: ignore email_verified = Column(Boolean, nullable=True) git_user_name = Column(String, nullable=True) git_user_email = Column(String, nullable=True) + sandbox_grouping_strategy = Column(String, nullable=True) # Relationships role = relationship('Role', back_populates='users') diff --git a/enterprise/storage/user_settings.py b/enterprise/storage/user_settings.py index 96ccc9653e..3e62c3e930 100644 --- a/enterprise/storage/user_settings.py +++ b/enterprise/storage/user_settings.py @@ -27,6 +27,7 @@ class UserSettings(Base): # type: ignore ) sandbox_base_container_image = Column(String, nullable=True) sandbox_runtime_container_image = Column(String, nullable=True) + sandbox_grouping_strategy = Column(String, nullable=True) user_version = Column(Integer, nullable=False, default=0) accepted_tos = Column(DateTime, nullable=True) mcp_config = Column(JSON, nullable=True) diff --git a/frontend/__tests__/utils/get-git-path.test.ts b/frontend/__tests__/utils/get-git-path.test.ts index d1507228fc..2adfc232d4 100644 --- a/frontend/__tests__/utils/get-git-path.test.ts +++ b/frontend/__tests__/utils/get-git-path.test.ts @@ -2,27 +2,29 @@ import { describe, it, expect } from "vitest"; import { getGitPath } from "#/utils/get-git-path"; describe("getGitPath", () => { - it("should return /workspace/project when no repository is selected", () => { - expect(getGitPath(null)).toBe("/workspace/project"); - expect(getGitPath(undefined)).toBe("/workspace/project"); + const conversationId = "abc123"; + + it("should return /workspace/project/{conversationId} when no repository is selected", () => { + expect(getGitPath(conversationId, null)).toBe(`/workspace/project/${conversationId}`); + expect(getGitPath(conversationId, undefined)).toBe(`/workspace/project/${conversationId}`); }); it("should handle standard owner/repo format (GitHub)", () => { - expect(getGitPath("OpenHands/OpenHands")).toBe("/workspace/project/OpenHands"); - expect(getGitPath("facebook/react")).toBe("/workspace/project/react"); + expect(getGitPath(conversationId, "OpenHands/OpenHands")).toBe(`/workspace/project/${conversationId}/OpenHands`); + expect(getGitPath(conversationId, "facebook/react")).toBe(`/workspace/project/${conversationId}/react`); }); it("should handle nested group paths (GitLab)", () => { - expect(getGitPath("modernhealth/frontend-guild/pan")).toBe("/workspace/project/pan"); - expect(getGitPath("group/subgroup/repo")).toBe("/workspace/project/repo"); - expect(getGitPath("a/b/c/d/repo")).toBe("/workspace/project/repo"); + expect(getGitPath(conversationId, "modernhealth/frontend-guild/pan")).toBe(`/workspace/project/${conversationId}/pan`); + expect(getGitPath(conversationId, "group/subgroup/repo")).toBe(`/workspace/project/${conversationId}/repo`); + expect(getGitPath(conversationId, "a/b/c/d/repo")).toBe(`/workspace/project/${conversationId}/repo`); }); it("should handle single segment paths", () => { - expect(getGitPath("repo")).toBe("/workspace/project/repo"); + expect(getGitPath(conversationId, "repo")).toBe(`/workspace/project/${conversationId}/repo`); }); it("should handle empty string", () => { - expect(getGitPath("")).toBe("/workspace/project"); + expect(getGitPath(conversationId, "")).toBe(`/workspace/project/${conversationId}`); }); }); diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index ce01e4f69b..6c6d766b69 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -18,6 +18,9 @@ const getSettingsQueryFn = async (): Promise => { git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email, is_new_user: false, v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled, + sandbox_grouping_strategy: + settings.sandbox_grouping_strategy ?? + DEFAULT_SETTINGS.sandbox_grouping_strategy, }; }; diff --git a/frontend/src/hooks/query/use-unified-get-git-changes.ts b/frontend/src/hooks/query/use-unified-get-git-changes.ts index 6b0856031c..70bc5f451f 100644 --- a/frontend/src/hooks/query/use-unified-get-git-changes.ts +++ b/frontend/src/hooks/query/use-unified-get-git-changes.ts @@ -27,7 +27,7 @@ export const useUnifiedGetGitChanges = () => { // Calculate git path based on selected repository const gitPath = React.useMemo( - () => getGitPath(selectedRepository), + () => getGitPath(conversationId, selectedRepository), [selectedRepository], ); diff --git a/frontend/src/hooks/query/use-unified-git-diff.ts b/frontend/src/hooks/query/use-unified-git-diff.ts index 33fedb497b..26bca16fce 100644 --- a/frontend/src/hooks/query/use-unified-git-diff.ts +++ b/frontend/src/hooks/query/use-unified-git-diff.ts @@ -32,7 +32,7 @@ export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => { const absoluteFilePath = React.useMemo(() => { if (!isV1Conversation) return config.filePath; - const gitPath = getGitPath(selectedRepository); + const gitPath = getGitPath(conversationId, selectedRepository); return `${gitPath}/${config.filePath}`; }, [isV1Conversation, selectedRepository, config.filePath]); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 10e9d885fd..648143fc2e 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -175,6 +175,12 @@ export enum I18nKey { SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION", SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS", SETTINGS$SOLVABILITY_ANALYSIS = "SETTINGS$SOLVABILITY_ANALYSIS", + SETTINGS$SANDBOX_GROUPING_STRATEGY = "SETTINGS$SANDBOX_GROUPING_STRATEGY", + SETTINGS$SANDBOX_GROUPING_NO_GROUPING = "SETTINGS$SANDBOX_GROUPING_NO_GROUPING", + SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST = "SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST", + SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED = "SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED", + SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS = "SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS", + SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY = "SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY", SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY", SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL", SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index abeba30110..d3f91ceec7 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -2799,6 +2799,102 @@ "tr": "Çözünürlük Analizini Etkinleştir", "uk": "Увімкнути аналіз розв'язності" }, + "SETTINGS$SANDBOX_GROUPING_STRATEGY": { + "en": "Sandbox Grouping Strategy", + "ja": "サンドボックスグループ化戦略", + "zh-CN": "沙盒分组策略", + "zh-TW": "沙盒分組策略", + "ko-KR": "샌드박스 그룹화 전략", + "de": "Sandbox-Gruppierungsstrategie", + "no": "Sandkasse-grupperingsstrategi", + "it": "Strategia di raggruppamento sandbox", + "pt": "Estratégia de agrupamento de sandbox", + "es": "Estrategia de agrupación de sandbox", + "ar": "استراتيجية تجميع صندوق الرمل", + "fr": "Stratégie de regroupement sandbox", + "tr": "Sandbox Gruplama Stratejisi", + "uk": "Стратегія групування пісочниці" + }, + "SETTINGS$SANDBOX_GROUPING_NO_GROUPING": { + "en": "No Grouping (new sandbox per conversation)", + "ja": "グループ化なし (会話ごとに新しいサンドボックス)", + "zh-CN": "不分组 (每个对话使用新沙盒)", + "zh-TW": "不分組 (每個對話使用新沙盒)", + "ko-KR": "그룹화 없음 (대화마다 새 샌드박스)", + "de": "Keine Gruppierung (neue Sandbox pro Gespräch)", + "no": "Ingen gruppering (ny sandkasse per samtale)", + "it": "Nessun raggruppamento (nuova sandbox per conversazione)", + "pt": "Sem agrupamento (novo sandbox por conversa)", + "es": "Sin agrupación (nuevo sandbox por conversación)", + "ar": "بدون تجميع (صندوق رمل جديد لكل محادثة)", + "fr": "Pas de regroupement (nouveau sandbox par conversation)", + "tr": "Gruplama Yok (konuşma başına yeni sandbox)", + "uk": "Без групування (нова пісочниця для кожної розмови)" + }, + "SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST": { + "en": "Group by Newest (add to most recent sandbox)", + "ja": "最新でグループ化 (最新のサンドボックスに追加)", + "zh-CN": "按最新分组 (添加到最近的沙盒)", + "zh-TW": "按最新分組 (添加到最近的沙盒)", + "ko-KR": "최신으로 그룹화 (가장 최근 샌드박스에 추가)", + "de": "Nach neuester gruppieren (zur neuesten Sandbox hinzufügen)", + "no": "Grupper etter nyeste (legg til i nyeste sandkasse)", + "it": "Raggruppa per più recente (aggiungi alla sandbox più recente)", + "pt": "Agrupar por mais recente (adicionar ao sandbox mais recente)", + "es": "Agrupar por más reciente (agregar al sandbox más reciente)", + "ar": "التجميع حسب الأحدث (إضافة إلى أحدث صندوق رمل)", + "fr": "Regrouper par le plus récent (ajouter au sandbox le plus récent)", + "tr": "En Yeniye Göre Grupla (en yeni sandbox'a ekle)", + "uk": "Групувати за найновішим (додати до найновішої пісочниці)" + }, + "SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED": { + "en": "Least Recently Used (add to oldest sandbox)", + "ja": "最も古い (最も古いサンドボックスに追加)", + "zh-CN": "最近最少使用 (添加到最旧的沙盒)", + "zh-TW": "最近最少使用 (添加到最舊的沙盒)", + "ko-KR": "가장 오래된 것 (가장 오래된 샌드박스에 추가)", + "de": "Am ältesten (zur ältesten Sandbox hinzufügen)", + "no": "Eldst (legg til i eldste sandkasse)", + "it": "Meno usato di recente (aggiungi alla sandbox più vecchia)", + "pt": "Menos usado recentemente (adicionar ao sandbox mais antigo)", + "es": "Menos usado recientemente (agregar al sandbox más antiguo)", + "ar": "الأقل استخدامًا مؤخرًا (إضافة إلى أقدم صندوق رمل)", + "fr": "Le moins récemment utilisé (ajouter au sandbox le plus ancien)", + "tr": "En Az Kullanılan (en eski sandbox'a ekle)", + "uk": "Найменш нещодавно використана (додати до найстаршої пісочниці)" + }, + "SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS": { + "en": "Fewest Conversations (add to least busy sandbox)", + "ja": "会話数が最少 (最も空いているサンドボックスに追加)", + "zh-CN": "最少对话 (添加到最空闲的沙盒)", + "zh-TW": "最少對話 (添加到最空閒的沙盒)", + "ko-KR": "대화 수가 가장 적은 (가장 한가한 샌드박스에 추가)", + "de": "Wenigste Gespräche (zur am wenigsten beschäftigten Sandbox hinzufügen)", + "no": "Færrest samtaler (legg til i minst opptatt sandkasse)", + "it": "Meno conversazioni (aggiungi alla sandbox meno occupata)", + "pt": "Menos conversas (adicionar ao sandbox menos ocupado)", + "es": "Menos conversaciones (agregar al sandbox menos ocupado)", + "ar": "أقل محادثات (إضافة إلى صندوق الرمل الأقل انشغالاً)", + "fr": "Moins de conversations (ajouter au sandbox le moins occupé)", + "tr": "En Az Konuşma (en az meşgul sandbox'a ekle)", + "uk": "Найменше розмов (додати до найменш зайнятої пісочниці)" + }, + "SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY": { + "en": "Add to Any (use first available sandbox)", + "ja": "任意に追加 (最初に利用可能なサンドボックスを使用)", + "zh-CN": "添加到任意 (使用第一个可用的沙盒)", + "zh-TW": "添加到任意 (使用第一個可用的沙盒)", + "ko-KR": "아무 곳에나 추가 (첫 번째 사용 가능한 샌드박스 사용)", + "de": "Zu beliebig hinzufügen (erste verfügbare Sandbox verwenden)", + "no": "Legg til i hvilken som helst (bruk første tilgjengelige sandkasse)", + "it": "Aggiungi a qualsiasi (usa la prima sandbox disponibile)", + "pt": "Adicionar a qualquer (usar o primeiro sandbox disponível)", + "es": "Agregar a cualquiera (usar el primer sandbox disponible)", + "ar": "إضافة إلى أي (استخدام أول صندوق رمل متاح)", + "fr": "Ajouter à n'importe lequel (utiliser le premier sandbox disponible)", + "tr": "Herhangi Birine Ekle (ilk uygun sandbox'ı kullan)", + "uk": "Додати до будь-якої (використовувати першу доступну пісочницю)" + }, "SETTINGS$SEARCH_API_KEY": { "en": "Search API Key (Tavily)", "ja": "検索APIキー (Tavily)", diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx index 8226488468..43753fbec7 100644 --- a/frontend/src/routes/app-settings.tsx +++ b/frontend/src/routes/app-settings.tsx @@ -8,6 +8,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings"; import { BrandButton } from "#/components/features/settings/brand-button"; import { SettingsSwitch } from "#/components/features/settings/settings-switch"; import { SettingsInput } from "#/components/features/settings/settings-input"; +import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; import { I18nKey } from "#/i18n/declaration"; import { LanguageInput } from "#/components/features/settings/app-settings/language-input"; import { handleCaptureConsent } from "#/utils/handle-capture-consent"; @@ -19,6 +20,11 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message" import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton"; import { useConfig } from "#/hooks/query/use-config"; import { parseMaxBudgetPerTask } from "#/utils/settings-utils"; +import { + SandboxGroupingStrategy, + SandboxGroupingStrategyOptions, +} from "#/types/settings"; +import { ENABLE_SANDBOX_GROUPING } from "#/utils/feature-flags"; import { createPermissionGuard } from "#/utils/org/permission-guard"; export const clientLoader = createPermissionGuard( @@ -49,6 +55,12 @@ function AppSettingsScreen() { solvabilityAnalysisSwitchHasChanged, setSolvabilityAnalysisSwitchHasChanged, ] = React.useState(false); + const [ + sandboxGroupingStrategyHasChanged, + setSandboxGroupingStrategyHasChanged, + ] = React.useState(false); + const [selectedSandboxGroupingStrategy, setSelectedSandboxGroupingStrategy] = + React.useState(null); const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] = React.useState(false); const [gitUserNameHasChanged, setGitUserNameHasChanged] = @@ -75,6 +87,11 @@ function AppSettingsScreen() { const enableSolvabilityAnalysis = formData.get("enable-solvability-analysis-switch")?.toString() === "on"; + const sandboxGroupingStrategy = + selectedSandboxGroupingStrategy || + settings?.sandbox_grouping_strategy || + DEFAULT_SETTINGS.sandbox_grouping_strategy; + const maxBudgetPerTaskValue = formData .get("max-budget-per-task-input") ?.toString(); @@ -94,6 +111,7 @@ function AppSettingsScreen() { enable_sound_notifications: enableSoundNotifications, enable_proactive_conversation_starters: enableProactiveConversations, enable_solvability_analysis: enableSolvabilityAnalysis, + sandbox_grouping_strategy: sandboxGroupingStrategy, max_budget_per_task: maxBudgetPerTask, git_user_name: gitUserName, git_user_email: gitUserEmail, @@ -112,6 +130,8 @@ function AppSettingsScreen() { setAnalyticsSwitchHasChanged(false); setSoundNotificationsSwitchHasChanged(false); setProactiveConversationsSwitchHasChanged(false); + setSandboxGroupingStrategyHasChanged(false); + setSelectedSandboxGroupingStrategy(null); setMaxBudgetPerTaskHasChanged(false); setGitUserNameHasChanged(false); setGitUserEmailHasChanged(false); @@ -159,6 +179,15 @@ function AppSettingsScreen() { ); }; + const handleSandboxGroupingStrategyChange = (key: React.Key | null) => { + const newStrategy = key?.toString() as SandboxGroupingStrategy | undefined; + setSelectedSandboxGroupingStrategy(newStrategy || null); + const currentStrategy = + settings?.sandbox_grouping_strategy || + DEFAULT_SETTINGS.sandbox_grouping_strategy; + setSandboxGroupingStrategyHasChanged(newStrategy !== currentStrategy); + }; + const checkIfMaxBudgetPerTaskHasChanged = (value: string) => { const newValue = parseMaxBudgetPerTask(value); const currentValue = settings?.max_budget_per_task; @@ -181,6 +210,7 @@ function AppSettingsScreen() { !soundNotificationsSwitchHasChanged && !proactiveConversationsSwitchHasChanged && !solvabilityAnalysisSwitchHasChanged && + !sandboxGroupingStrategyHasChanged && !maxBudgetPerTaskHasChanged && !gitUserNameHasChanged && !gitUserEmailHasChanged; @@ -244,6 +274,26 @@ function AppSettingsScreen() { )} + {ENABLE_SANDBOX_GROUPING() && ( + ({ + key, + label: t(`SETTINGS$SANDBOX_GROUPING_${key}` as I18nKey), + }))} + selectedKey={ + selectedSandboxGroupingStrategy || + settings.sandbox_grouping_strategy || + DEFAULT_SETTINGS.sandbox_grouping_strategy + } + isClearable={false} + onSelectionChange={handleSandboxGroupingStrategyChange} + wrapperClassName="w-full max-w-[680px]" + /> + )} + {!settings?.v1_enabled && ( loadFeatureFlag("VSCODE_IN_NEW_TAB"); export const ENABLE_TRAJECTORY_REPLAY = () => loadFeatureFlag("TRAJECTORY_REPLAY"); export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING"); +export const ENABLE_SANDBOX_GROUPING = () => + loadFeatureFlag("SANDBOX_GROUPING"); diff --git a/frontend/src/utils/get-git-path.ts b/frontend/src/utils/get-git-path.ts index 15c8ff947e..39292b819f 100644 --- a/frontend/src/utils/get-git-path.ts +++ b/frontend/src/utils/get-git-path.ts @@ -7,10 +7,11 @@ * @returns The git path to use */ export function getGitPath( + conversationId: string, selectedRepository: string | null | undefined, ): string { if (!selectedRepository) { - return "/workspace/project"; + return `/workspace/project/${conversationId}`; } // Extract the repository name from the path @@ -18,5 +19,5 @@ export function getGitPath( const parts = selectedRepository.split("/"); const repoName = parts[parts.length - 1]; - return `/workspace/project/${repoName}`; + return `/workspace/project/${conversationId}/${repoName}`; } diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py index a30b40e56c..b7a4cc4dce 100644 --- a/openhands/app_server/app_conversation/app_conversation_models.py +++ b/openhands/app_server/app_conversation/app_conversation_models.py @@ -16,6 +16,10 @@ from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.llm import MetricsSnapshot from openhands.sdk.plugin import PluginSource from openhands.storage.data_models.conversation_metadata import ConversationTrigger +from openhands.storage.data_models.settings import SandboxGroupingStrategy + +# Re-export SandboxGroupingStrategy for backward compatibility +__all__ = ['SandboxGroupingStrategy'] class AgentType(Enum): diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 94b5740329..7bb59fedbf 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -88,6 +88,7 @@ from openhands.sdk.utils.paging import page_iterator from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode from openhands.storage.data_models.conversation_metadata import ConversationTrigger +from openhands.storage.data_models.settings import SandboxGroupingStrategy from openhands.tools.preset.default import ( get_default_tools, ) @@ -128,6 +129,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): jwt_service: JwtService sandbox_startup_timeout: int sandbox_startup_poll_frequency: int + max_num_conversations_per_sandbox: int httpx_client: httpx.AsyncClient web_url: str | None openhands_provider_base_url: str | None @@ -135,6 +137,11 @@ class LiveStatusAppConversationService(AppConversationServiceBase): app_mode: str | None = None tavily_api_key: str | None = None + async def _get_sandbox_grouping_strategy(self) -> SandboxGroupingStrategy: + """Get the sandbox grouping strategy from user settings.""" + user_info = await self.user_context.get_user_info() + return user_info.sandbox_grouping_strategy + async def search_app_conversations( self, title__contains: str | None = None, @@ -255,11 +262,20 @@ class LiveStatusAppConversationService(AppConversationServiceBase): ) assert sandbox_spec is not None + # Set up conversation id + conversation_id = request.conversation_id or uuid4() + + # Setup working dir based on grouping + working_dir = sandbox_spec.working_dir + sandbox_grouping_strategy = await self._get_sandbox_grouping_strategy() + if sandbox_grouping_strategy != SandboxGroupingStrategy.NO_GROUPING: + working_dir = f'{working_dir}/{conversation_id.hex}' + # Run setup scripts remote_workspace = AsyncRemoteWorkspace( host=agent_server_url, api_key=sandbox.session_api_key, - working_dir=sandbox_spec.working_dir, + working_dir=working_dir, ) async for updated_task in self.run_setup_scripts( task, sandbox, remote_workspace, agent_server_url @@ -270,13 +286,13 @@ class LiveStatusAppConversationService(AppConversationServiceBase): start_conversation_request = ( await self._build_start_conversation_request_for_user( sandbox, + conversation_id, request.initial_message, request.system_message_suffix, request.git_provider, - sandbox_spec.working_dir, + working_dir, request.agent_type, request.llm_model, - request.conversation_id, remote_workspace=remote_workspace, selected_repository=request.selected_repository, plugins=request.plugins, @@ -495,21 +511,157 @@ class LiveStatusAppConversationService(AppConversationServiceBase): result[stored_conversation.sandbox_id].append(stored_conversation.id) return result + async def _find_running_sandbox_for_user(self) -> SandboxInfo | None: + """Find a running sandbox for the current user based on the grouping strategy. + + Returns: + SandboxInfo if a running sandbox is found, None otherwise. + """ + try: + user_id = await self.user_context.get_user_id() + sandbox_grouping_strategy = await self._get_sandbox_grouping_strategy() + + # If no grouping, return None to force creation of a new sandbox + if sandbox_grouping_strategy == SandboxGroupingStrategy.NO_GROUPING: + return None + + # Collect all running sandboxes for this user + running_sandboxes = [] + page_id = None + while True: + page = await self.sandbox_service.search_sandboxes( + page_id=page_id, limit=100 + ) + + for sandbox in page.items: + if ( + sandbox.status == SandboxStatus.RUNNING + and sandbox.created_by_user_id == user_id + ): + running_sandboxes.append(sandbox) + + if page.next_page_id is None: + break + page_id = page.next_page_id + + if not running_sandboxes: + return None + + # Apply the grouping strategy + return await self._select_sandbox_by_strategy( + running_sandboxes, sandbox_grouping_strategy + ) + + except Exception as e: + _logger.warning( + f'Error finding running sandbox for user: {e}', exc_info=True + ) + return None + + async def _select_sandbox_by_strategy( + self, + running_sandboxes: list[SandboxInfo], + sandbox_grouping_strategy: SandboxGroupingStrategy, + ) -> SandboxInfo | None: + """Select a sandbox from the list based on the configured grouping strategy. + + Args: + running_sandboxes: List of running sandboxes for the user + sandbox_grouping_strategy: The strategy to use for selection + + Returns: + Selected sandbox based on the strategy, or None if no sandbox is available + (e.g., all sandboxes have reached max_num_conversations_per_sandbox) + """ + # Get conversation counts for filtering by max_num_conversations_per_sandbox + sandbox_conversation_counts = await self._get_conversation_counts_by_sandbox( + [s.id for s in running_sandboxes] + ) + + # Filter out sandboxes that have reached the max number of conversations + available_sandboxes = [ + s + for s in running_sandboxes + if sandbox_conversation_counts.get(s.id, 0) + < self.max_num_conversations_per_sandbox + ] + + if not available_sandboxes: + # All sandboxes have reached the max - need to create a new one + return None + + if sandbox_grouping_strategy == SandboxGroupingStrategy.ADD_TO_ANY: + # Return the first available sandbox + return available_sandboxes[0] + + elif sandbox_grouping_strategy == SandboxGroupingStrategy.GROUP_BY_NEWEST: + # Return the most recently created sandbox + return max(available_sandboxes, key=lambda s: s.created_at) + + elif sandbox_grouping_strategy == SandboxGroupingStrategy.LEAST_RECENTLY_USED: + # Return the least recently created sandbox (oldest) + return min(available_sandboxes, key=lambda s: s.created_at) + + elif sandbox_grouping_strategy == SandboxGroupingStrategy.FEWEST_CONVERSATIONS: + # Return the one with fewest conversations + return min( + available_sandboxes, + key=lambda s: sandbox_conversation_counts.get(s.id, 0), + ) + + else: + # Default fallback - return first sandbox + return available_sandboxes[0] + + async def _get_conversation_counts_by_sandbox( + self, sandbox_ids: list[str] + ) -> dict[str, int]: + """Get the count of conversations for each sandbox. + + Args: + sandbox_ids: List of sandbox IDs to count conversations for + + Returns: + Dictionary mapping sandbox_id to conversation count + """ + try: + # Query count for each sandbox individually + # This is efficient since there are at most ~8 running sandboxes per user + counts: dict[str, int] = {} + for sandbox_id in sandbox_ids: + count = await self.app_conversation_info_service.count_app_conversation_info( + sandbox_id__eq=sandbox_id + ) + counts[sandbox_id] = count + return counts + except Exception as e: + _logger.warning( + f'Error counting conversations by sandbox: {e}', exc_info=True + ) + # Return empty counts on error - will default to first sandbox + return {} + async def _wait_for_sandbox_start( self, task: AppConversationStartTask ) -> AsyncGenerator[AppConversationStartTask, None]: """Wait for sandbox to start and return info.""" # Get or create the sandbox if not task.request.sandbox_id: - # Convert conversation_id to hex string if present - sandbox_id_str = ( - task.request.conversation_id.hex - if task.request.conversation_id is not None - else None - ) - sandbox = await self.sandbox_service.start_sandbox( - sandbox_id=sandbox_id_str - ) + # First try to find a running sandbox for the current user + sandbox = await self._find_running_sandbox_for_user() + if sandbox is None: + # No running sandbox found, start a new one + + # Convert conversation_id to hex string if present + sandbox_id_str = ( + task.request.conversation_id.hex + if task.request.conversation_id is not None + else None + ) + + sandbox = await self.sandbox_service.start_sandbox( + sandbox_id=sandbox_id_str + ) task.sandbox_id = sandbox.id else: sandbox_info = await self.sandbox_service.get_sandbox( @@ -1133,7 +1285,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): async def _finalize_conversation_request( self, agent: Agent, - conversation_id: UUID | None, + conversation_id: UUID, user: UserInfo, workspace: LocalWorkspace, initial_message: SendMessageRequest | None, @@ -1211,13 +1363,13 @@ class LiveStatusAppConversationService(AppConversationServiceBase): async def _build_start_conversation_request_for_user( self, sandbox: SandboxInfo, + conversation_id: UUID, initial_message: SendMessageRequest | None, system_message_suffix: str | None, git_provider: ProviderType | None, working_dir: str, agent_type: AgentType = AgentType.DEFAULT, llm_model: str | None = None, - conversation_id: UUID | None = None, remote_workspace: AsyncRemoteWorkspace | None = None, selected_repository: str | None = None, plugins: list[PluginSpec] | None = None, @@ -1614,6 +1766,10 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): sandbox_startup_poll_frequency: int = Field( default=2, description='The frequency to poll for sandbox readiness' ) + max_num_conversations_per_sandbox: int = Field( + default=20, + description='The maximum number of conversations allowed per sandbox', + ) init_git_in_empty_workspace: bool = Field( default=True, description='Whether to initialize a git repo when the workspace is empty', @@ -1705,6 +1861,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): jwt_service=jwt_service, sandbox_startup_timeout=self.sandbox_startup_timeout, sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency, + max_num_conversations_per_sandbox=self.max_num_conversations_per_sandbox, httpx_client=httpx_client, web_url=web_url, openhands_provider_base_url=config.openhands_provider_base_url, diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 547ca6e252..fa73aa4d52 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -41,7 +41,7 @@ from openhands.app_server.config import ( depends_httpx_client, depends_sandbox_service, ) -from openhands.app_server.sandbox.sandbox_models import SandboxStatus +from openhands.app_server.sandbox.sandbox_models import AGENT_SERVER, SandboxStatus from openhands.app_server.sandbox.sandbox_service import SandboxService from openhands.app_server.services.db_session_injector import set_db_session_keep_open from openhands.app_server.services.httpx_client_injector import ( @@ -614,7 +614,7 @@ async def _try_delete_v1_conversation( # Delete the sandbox in the background asyncio.create_task( - _delete_sandbox_and_close_connections( + _finalize_delete_and_close_connections( sandbox_service, app_conversation_info.sandbox_id, db_session, @@ -628,14 +628,18 @@ async def _try_delete_v1_conversation( return result -async def _delete_sandbox_and_close_connections( +async def _finalize_delete_and_close_connections( sandbox_service: SandboxService, sandbox_id: str, db_session: AsyncSession, httpx_client: httpx.AsyncClient, ): try: - await sandbox_service.delete_sandbox(sandbox_id) + num_conversations_in_sandbox = await _get_num_conversations_in_sandbox( + sandbox_service, sandbox_id, httpx_client + ) + if num_conversations_in_sandbox == 0: + await sandbox_service.delete_sandbox(sandbox_id) await db_session.commit() finally: await asyncio.gather( @@ -646,6 +650,28 @@ async def _delete_sandbox_and_close_connections( ) +async def _get_num_conversations_in_sandbox( + sandbox_service: SandboxService, + sandbox_id: str, + httpx_client: httpx.AsyncClient, +) -> int: + try: + sandbox = await sandbox_service.get_sandbox(sandbox_id) + if not sandbox or not sandbox.exposed_urls: + return 0 + agent_server_url = next( + u for u in sandbox.exposed_urls if u.name == AGENT_SERVER + ) + response = await httpx_client.get( + f'{agent_server_url.url}/api/conversations/count', + headers={'X-Session-API-Key': sandbox.session_api_key}, + ) + result = int(response.content) + return result + except Exception: + return 0 + + async def _delete_v0_conversation(conversation_id: str, user_id: str | None) -> bool: """Delete a V0 conversation using the legacy logic.""" conversation_store = await ConversationStoreImpl.get_instance(config, user_id) diff --git a/openhands/storage/data_models/settings.py b/openhands/storage/data_models/settings.py index 1600acd3ad..a27c4e1b20 100644 --- a/openhands/storage/data_models/settings.py +++ b/openhands/storage/data_models/settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +from enum import Enum from typing import Annotated from pydantic import ( @@ -19,6 +20,20 @@ from openhands.core.config.utils import load_openhands_config from openhands.storage.data_models.secrets import Secrets +class SandboxGroupingStrategy(str, Enum): + """Strategy for grouping conversations within sandboxes.""" + + NO_GROUPING = 'NO_GROUPING' # Default - each conversation gets its own sandbox + GROUP_BY_NEWEST = 'GROUP_BY_NEWEST' # Add to the most recently created sandbox + LEAST_RECENTLY_USED = ( + 'LEAST_RECENTLY_USED' # Add to the least recently used sandbox + ) + FEWEST_CONVERSATIONS = ( + 'FEWEST_CONVERSATIONS' # Add to sandbox with fewest conversations + ) + ADD_TO_ANY = 'ADD_TO_ANY' # Add to any available sandbox (first found) + + class Settings(BaseModel): """Persisted settings for OpenHands sessions""" @@ -54,6 +69,9 @@ class Settings(BaseModel): git_user_name: str | None = None git_user_email: str | None = None v1_enabled: bool = True + sandbox_grouping_strategy: SandboxGroupingStrategy = ( + SandboxGroupingStrategy.NO_GROUPING + ) model_config = ConfigDict( validate_assignment=True, diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index ad9b4edb46..cf32cfaf05 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -6,7 +6,7 @@ import os import zipfile from datetime import datetime from unittest.mock import AsyncMock, Mock, patch -from uuid import UUID, uuid4 +from uuid import uuid4 import pytest from pydantic import SecretStr @@ -29,6 +29,7 @@ from openhands.app_server.sandbox.sandbox_models import ( AGENT_SERVER, ExposedUrl, SandboxInfo, + SandboxPage, SandboxStatus, ) from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo @@ -42,6 +43,7 @@ from openhands.sdk.workspace import LocalWorkspace from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace from openhands.server.types import AppMode from openhands.storage.data_models.conversation_metadata import ConversationTrigger +from openhands.storage.data_models.settings import SandboxGroupingStrategy # Env var used by openhands SDK LLM to skip context-window validation (e.g. for gpt-4 in tests) _ALLOW_SHORT_CONTEXT_WINDOWS = 'ALLOW_SHORT_CONTEXT_WINDOWS' @@ -92,6 +94,7 @@ class TestLiveStatusAppConversationService: jwt_service=self.mock_jwt_service, sandbox_startup_timeout=30, sandbox_startup_poll_frequency=1, + max_num_conversations_per_sandbox=20, httpx_client=self.mock_httpx_client, web_url='https://test.example.com', openhands_provider_base_url='https://provider.example.com', @@ -105,6 +108,8 @@ class TestLiveStatusAppConversationService: self.mock_user.llm_model = 'gpt-4' self.mock_user.llm_base_url = 'https://api.openai.com/v1' self.mock_user.llm_api_key = 'test_api_key' + # Use ADD_TO_ANY for tests to maintain old behavior + self.mock_user.sandbox_grouping_strategy = SandboxGroupingStrategy.ADD_TO_ANY self.mock_user.confirmation_mode = False self.mock_user.search_api_key = None # Default to None self.mock_user.condenser_max_size = None # Default to None @@ -1091,11 +1096,12 @@ class TestLiveStatusAppConversationService: workspace = LocalWorkspace(working_dir='/test') secrets = {'test': StaticSecret(value='secret')} + test_conversation_id = uuid4() # Act result = await self.service._finalize_conversation_request( mock_agent, - None, + test_conversation_id, self.mock_user, workspace, None, @@ -1108,7 +1114,7 @@ class TestLiveStatusAppConversationService: # Assert assert isinstance(result, StartConversationRequest) - assert isinstance(result.conversation_id, UUID) + assert result.conversation_id == test_conversation_id @pytest.mark.asyncio async def test_finalize_conversation_request_skills_loading_fails(self): @@ -1179,13 +1185,13 @@ class TestLiveStatusAppConversationService: # Act result = await self.service._build_start_conversation_request_for_user( sandbox=self.mock_sandbox, + conversation_id=uuid4(), initial_message=None, system_message_suffix='Test suffix', git_provider=ProviderType.GITHUB, working_dir='/test/dir', agent_type=AgentType.DEFAULT, llm_model='gpt-4', - conversation_id=None, remote_workspace=None, selected_repository='test/repo', ) @@ -1215,6 +1221,98 @@ class TestLiveStatusAppConversationService: self.service._finalize_conversation_request.assert_called_once() @pytest.mark.asyncio + async def test_find_running_sandbox_for_user_found(self): + """Test _find_running_sandbox_for_user when a running sandbox is found.""" + # Arrange + user_id = 'test_user_123' + self.mock_user_context.get_user_id.return_value = user_id + + # Create mock sandboxes + running_sandbox = Mock(spec=SandboxInfo) + running_sandbox.id = 'sandbox_1' + running_sandbox.status = SandboxStatus.RUNNING + running_sandbox.created_by_user_id = user_id + + other_user_sandbox = Mock(spec=SandboxInfo) + other_user_sandbox.id = 'sandbox_2' + other_user_sandbox.status = SandboxStatus.RUNNING + other_user_sandbox.created_by_user_id = 'other_user' + + paused_sandbox = Mock(spec=SandboxInfo) + paused_sandbox.id = 'sandbox_3' + paused_sandbox.status = SandboxStatus.PAUSED + paused_sandbox.created_by_user_id = user_id + + # Mock sandbox service search + mock_page = Mock(spec=SandboxPage) + mock_page.items = [other_user_sandbox, running_sandbox, paused_sandbox] + mock_page.next_page_id = None + self.mock_sandbox_service.search_sandboxes = AsyncMock(return_value=mock_page) + + # Act + result = await self.service._find_running_sandbox_for_user() + + # Assert + assert result == running_sandbox + self.mock_user_context.get_user_id.assert_called_once() + self.mock_sandbox_service.search_sandboxes.assert_called_once_with( + page_id=None, limit=100 + ) + + @pytest.mark.asyncio + async def test_find_running_sandbox_for_user_not_found(self): + """Test _find_running_sandbox_for_user when no running sandbox is found.""" + # Arrange + user_id = 'test_user_123' + self.mock_user_context.get_user_id.return_value = user_id + + # Create mock sandboxes (none running for this user) + other_user_sandbox = Mock(spec=SandboxInfo) + other_user_sandbox.id = 'sandbox_1' + other_user_sandbox.status = SandboxStatus.RUNNING + other_user_sandbox.created_by_user_id = 'other_user' + + paused_sandbox = Mock(spec=SandboxInfo) + paused_sandbox.id = 'sandbox_2' + paused_sandbox.status = SandboxStatus.PAUSED + paused_sandbox.created_by_user_id = user_id + + # Mock sandbox service search + mock_page = Mock(spec=SandboxPage) + mock_page.items = [other_user_sandbox, paused_sandbox] + mock_page.next_page_id = None + self.mock_sandbox_service.search_sandboxes = AsyncMock(return_value=mock_page) + + # Act + result = await self.service._find_running_sandbox_for_user() + + # Assert + assert result is None + self.mock_user_context.get_user_id.assert_called_once() + self.mock_sandbox_service.search_sandboxes.assert_called_once_with( + page_id=None, limit=100 + ) + + @pytest.mark.asyncio + async def test_find_running_sandbox_for_user_exception_handling(self): + """Test _find_running_sandbox_for_user handles exceptions gracefully.""" + # Arrange + self.mock_user_context.get_user_id.side_effect = Exception('User context error') + + # Act + with patch( + 'openhands.app_server.app_conversation.live_status_app_conversation_service._logger' + ) as mock_logger: + result = await self.service._find_running_sandbox_for_user() + + # Assert + assert result is None + mock_logger.warning.assert_called_once() + assert ( + 'Error finding running sandbox for user' + in mock_logger.warning.call_args[0][0] + ) + async def test_export_conversation_success(self): """Test successful download of conversation trajectory.""" # Arrange @@ -2052,6 +2150,7 @@ class TestLiveStatusAppConversationService: await self.service._build_start_conversation_request_for_user( sandbox=self.mock_sandbox, + conversation_id=uuid4(), initial_message=None, system_message_suffix=None, git_provider=None, @@ -2088,6 +2187,7 @@ class TestLiveStatusAppConversationService: await self.service._build_start_conversation_request_for_user( sandbox=self.mock_sandbox, + conversation_id=uuid4(), initial_message=None, system_message_suffix=None, git_provider=None, @@ -2243,6 +2343,7 @@ class TestPluginHandling: jwt_service=self.mock_jwt_service, sandbox_startup_timeout=30, sandbox_startup_poll_frequency=1, + max_num_conversations_per_sandbox=20, httpx_client=self.mock_httpx_client, web_url='https://test.example.com', openhands_provider_base_url='https://provider.example.com', @@ -2726,11 +2827,12 @@ class TestPluginHandling: # Act await self.service._build_start_conversation_request_for_user( - self.mock_sandbox, - None, - None, - None, - '/workspace', + sandbox=self.mock_sandbox, + conversation_id=uuid4(), + initial_message=None, + system_message_suffix=None, + git_provider=None, + working_dir='/workspace', plugins=plugins, ) @@ -2754,11 +2856,12 @@ class TestPluginHandling: # Act await self.service._build_start_conversation_request_for_user( - self.mock_sandbox, - None, - None, - None, - '/workspace', + sandbox=self.mock_sandbox, + conversation_id=uuid4(), + initial_message=None, + system_message_suffix=None, + git_provider=None, + working_dir='/workspace', ) # Assert diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py index fc305d170e..7fa64ab12a 100644 --- a/tests/unit/server/data_models/test_conversation.py +++ b/tests/unit/server/data_models/test_conversation.py @@ -2189,6 +2189,7 @@ async def test_delete_v1_conversation_with_sub_conversations(): jwt_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, + max_num_conversations_per_sandbox=20, httpx_client=mock_httpx_client, web_url=None, openhands_provider_base_url=None, @@ -2312,6 +2313,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations(): jwt_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, + max_num_conversations_per_sandbox=20, httpx_client=mock_httpx_client, web_url=None, openhands_provider_base_url=None, @@ -2465,6 +2467,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error(): jwt_service=MagicMock(), sandbox_startup_timeout=120, sandbox_startup_poll_frequency=2, + max_num_conversations_per_sandbox=20, httpx_client=mock_httpx_client, web_url=None, openhands_provider_base_url=None,