mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user