feat: Add configurable sandbox reuse with grouping strategies (#11922)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2026-03-16 05:19:31 -06:00
committed by GitHub
parent 4dfcd68153
commit d591b140c8
22 changed files with 569 additions and 46 deletions

View File

@@ -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')

View File

@@ -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')

View File

@@ -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

View File

@@ -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')

View File

@@ -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)

View File

@@ -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}`);
});
});

View File

@@ -18,6 +18,9 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
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,
};
};

View File

@@ -27,7 +27,7 @@ export const useUnifiedGetGitChanges = () => {
// Calculate git path based on selected repository
const gitPath = React.useMemo(
() => getGitPath(selectedRepository),
() => getGitPath(conversationId, selectedRepository),
[selectedRepository],
);

View File

@@ -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]);

View File

@@ -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",

View File

@@ -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)",

View File

@@ -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<SandboxGroupingStrategy | null>(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() {
</SettingsSwitch>
)}
{ENABLE_SANDBOX_GROUPING() && (
<SettingsDropdownInput
testId="sandbox-grouping-strategy-input"
name="sandbox-grouping-strategy-input"
label={t(I18nKey.SETTINGS$SANDBOX_GROUPING_STRATEGY)}
items={Object.keys(SandboxGroupingStrategyOptions).map((key) => ({
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 && (
<SettingsInput
testId="max-budget-per-task-input"

View File

@@ -33,6 +33,7 @@ export const DEFAULT_SETTINGS: Settings = {
git_user_name: "openhands",
git_user_email: "openhands@all-hands.dev",
v1_enabled: false,
sandbox_grouping_strategy: "NO_GROUPING",
};
/**

View File

@@ -8,6 +8,17 @@ export const ProviderOptions = {
enterprise_sso: "enterprise_sso",
} as const;
export const SandboxGroupingStrategyOptions = {
NO_GROUPING: "NO_GROUPING",
GROUP_BY_NEWEST: "GROUP_BY_NEWEST",
LEAST_RECENTLY_USED: "LEAST_RECENTLY_USED",
FEWEST_CONVERSATIONS: "FEWEST_CONVERSATIONS",
ADD_TO_ANY: "ADD_TO_ANY",
} as const;
export type SandboxGroupingStrategy =
keyof typeof SandboxGroupingStrategyOptions;
export type Provider = keyof typeof ProviderOptions;
export type ProviderToken = {
@@ -67,4 +78,5 @@ export type Settings = {
git_user_name?: string;
git_user_email?: string;
v1_enabled?: boolean;
sandbox_grouping_strategy?: SandboxGroupingStrategy;
};

View File

@@ -18,3 +18,5 @@ export const VSCODE_IN_NEW_TAB = () => 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");

View File

@@ -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}`;
}

View File

@@ -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):

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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,