feat: add /clear endpoint for V1 conversations (#12786)

Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
MkDev11
2026-03-19 07:13:58 -07:00
committed by GitHub
parent 3a9f00aa37
commit 0ec962e96b
26 changed files with 884 additions and 56 deletions

View File

@@ -84,6 +84,14 @@ class AppConversationInfoService(ABC):
List of sub-conversation IDs
"""
@abstractmethod
async def count_conversations_by_sandbox_id(self, sandbox_id: str) -> int:
"""Count V1 conversations that reference the given sandbox.
Used to decide whether a sandbox can be safely deleted when a
conversation is removed (only delete if count is 0).
"""
# Mutators
@abstractmethod

View File

@@ -77,8 +77,20 @@ class AppConversationService(ABC):
id, starting a conversation, attaching a callback, and then running the
conversation.
Yields an instance of AppConversationStartTask as updates occur, which can be used to determine
the progress of the task.
This method returns an async iterator that yields the same
AppConversationStartTask repeatedly as status updates occur. Callers
should iterate until the task reaches a terminal status::
async for task in service.start_app_conversation(request):
if task.status in (
AppConversationStartTaskStatus.READY,
AppConversationStartTaskStatus.ERROR,
):
break
Status progression: WORKING → WAITING_FOR_SANDBOX → PREPARING_REPOSITORY
→ RUNNING_SETUP_SCRIPT → SETTING_UP_GIT_HOOKS → SETTING_UP_SKILLS
→ STARTING_CONVERSATION → READY (or ERROR at any point).
"""
# This is an abstract method - concrete implementations should provide real values
from openhands.app_server.app_conversation.app_conversation_models import (
@@ -111,15 +123,21 @@ class AppConversationService(ABC):
"""
@abstractmethod
async def delete_app_conversation(self, conversation_id: UUID) -> bool:
async def delete_app_conversation(
self, conversation_id: UUID, skip_agent_server_delete: bool = False
) -> bool:
"""Delete a V1 conversation and all its associated data.
Args:
conversation_id: The UUID of the conversation to delete.
skip_agent_server_delete: If True, skip the agent server DELETE call.
This should be set when the sandbox is shared with other
conversations (e.g. created via /new) to avoid destabilizing
the shared runtime.
This method should:
1. Delete the conversation from the database
2. Call the agent server to delete the conversation
2. Call the agent server to delete the conversation (unless skipped)
3. Clean up any related data
Returns True if the conversation was deleted successfully, False otherwise.

View File

@@ -1740,13 +1740,19 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
conversations = await self._build_app_conversations([info])
return conversations[0]
async def delete_app_conversation(self, conversation_id: UUID) -> bool:
async def delete_app_conversation(
self, conversation_id: UUID, skip_agent_server_delete: bool = False
) -> bool:
"""Delete a V1 conversation and all its associated data.
This method will also cascade delete all sub-conversations of the parent.
Args:
conversation_id: The UUID of the conversation to delete.
skip_agent_server_delete: If True, skip the agent server DELETE call.
This should be set when the sandbox is shared with other
conversations (e.g. created via /new) to avoid destabilizing
the shared runtime.
"""
# Check if we have the required SQL implementation for transactional deletion
if not isinstance(
@@ -1772,8 +1778,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
await self._delete_sub_conversations(conversation_id)
# Now delete the parent conversation
# Delete from agent server if sandbox is running
await self._delete_from_agent_server(app_conversation)
# Delete from agent server if sandbox is running (skip if sandbox is shared)
if not skip_agent_server_delete:
await self._delete_from_agent_server(app_conversation)
# Delete from database using the conversation info from app_conversation
# AppConversation extends AppConversationInfo, so we can use it directly

View File

@@ -278,6 +278,14 @@ class SQLAppConversationInfoService(AppConversationInfoService):
rows = result_set.scalars().all()
return [UUID(row.conversation_id) for row in rows]
async def count_conversations_by_sandbox_id(self, sandbox_id: str) -> int:
query = await self._secure_select()
query = query.where(StoredConversationMetadata.sandbox_id == sandbox_id)
count_query = select(func.count()).select_from(query.subquery())
result = await self.db_session.execute(count_query)
count = result.scalar()
return count or 0
async def get_app_conversation_info(
self, conversation_id: UUID
) -> AppConversationInfo | None:

View File

@@ -87,6 +87,19 @@ def get_default_web_url() -> str | None:
return f'https://{web_host}'
def get_default_permitted_cors_origins() -> list[str]:
"""Get permitted CORS origins, falling back to legacy PERMITTED_CORS_ORIGINS env var.
The preferred configuration is via OH_PERMITTED_CORS_ORIGINS_0, _1, etc.
(handled by the pydantic from_env parser). This fallback supports the legacy
comma-separated PERMITTED_CORS_ORIGINS environment variable.
"""
legacy = os.getenv('PERMITTED_CORS_ORIGINS', '')
if legacy:
return [o.strip() for o in legacy.split(',') if o.strip()]
return []
def get_openhands_provider_base_url() -> str | None:
"""Return the base URL for the OpenHands provider, if configured."""
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or None
@@ -106,6 +119,14 @@ class AppServerConfig(OpenHandsModel):
default_factory=get_default_web_url,
description='The URL where OpenHands is running (e.g., http://localhost:3000)',
)
permitted_cors_origins: list[str] = Field(
default_factory=get_default_permitted_cors_origins,
description=(
'Additional permitted CORS origins for both the app server and agent '
'server containers. Configure via OH_PERMITTED_CORS_ORIGINS_0, _1, etc. '
'Falls back to legacy PERMITTED_CORS_ORIGINS env var.'
),
)
openhands_provider_base_url: str | None = Field(
default_factory=get_openhands_provider_base_url,
description='Base URL for the OpenHands provider',

View File

@@ -27,7 +27,6 @@ from openhands.app_server.sandbox.sandbox_models import (
SandboxStatus,
)
from openhands.app_server.sandbox.sandbox_service import (
ALLOW_CORS_ORIGINS_VARIABLE,
SESSION_API_KEY_VARIABLE,
WEBHOOK_CALLBACK_VARIABLE,
SandboxService,
@@ -91,6 +90,7 @@ class DockerSandboxService(SandboxService):
httpx_client: httpx.AsyncClient
max_num_sandboxes: int
web_url: str | None = None
permitted_cors_origins: list[str] = field(default_factory=list)
extra_hosts: dict[str, str] = field(default_factory=dict)
docker_client: docker.DockerClient = field(default_factory=get_docker_client)
startup_grace_seconds: int = STARTUP_GRACE_SECONDS
@@ -386,8 +386,18 @@ class DockerSandboxService(SandboxService):
# Set CORS origins for remote browser access when web_url is configured.
# This allows the agent-server container to accept requests from the
# frontend when running OpenHands on a remote machine.
# Each origin gets its own indexed env var (OH_ALLOW_CORS_ORIGINS_0, _1, etc.)
cors_origins: list[str] = []
if self.web_url:
env_vars[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url
cors_origins.append(self.web_url)
cors_origins.extend(self.permitted_cors_origins)
# Deduplicate while preserving order
seen: set[str] = set()
for origin in cors_origins:
if origin not in seen:
seen.add(origin)
idx = len(seen) - 1
env_vars[f'OH_ALLOW_CORS_ORIGINS_{idx}'] = origin
# Prepare port mappings and add port environment variables
# When using host network, container ports are directly accessible on the host
@@ -621,7 +631,7 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
get_sandbox_spec_service,
)
# Get web_url from global config for CORS support
# Get web_url and permitted_cors_origins from global config
config = get_global_config()
web_url = config.web_url
@@ -640,6 +650,7 @@ class DockerSandboxServiceInjector(SandboxServiceInjector):
httpx_client=httpx_client,
max_num_sandboxes=self.max_num_sandboxes,
web_url=web_url,
permitted_cors_origins=config.permitted_cors_origins,
extra_hosts=self.extra_hosts,
startup_grace_seconds=self.startup_grace_seconds,
use_host_network=self.use_host_network,