V1 Integration (#11183)

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
This commit is contained in:
Tim O'Farrell
2025-10-13 20:16:44 -06:00
committed by GitHub
parent 5076f21e86
commit f292f3a84d
115 changed files with 13086 additions and 264 deletions

View File

@@ -15,7 +15,8 @@ from fastapi import (
from fastapi.responses import JSONResponse
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.app_server import v1_router
from openhands.app_server.config import get_app_lifespan_service
from openhands.integrations.service_types import AuthenticationError
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
@@ -33,6 +34,7 @@ from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.shared import conversation_manager, server_config
from openhands.server.types import AppMode
from openhands.version import get_version
mcp_app = mcp_server.http_app(path='/mcp')
@@ -55,11 +57,17 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
yield
lifespans = [_lifespan, mcp_app.lifespan]
app_lifespan_ = get_app_lifespan_service()
if app_lifespan_:
lifespans.append(app_lifespan_.lifespan)
app = FastAPI(
title='OpenHands',
description='OpenHands: Code Less, Make More',
version=__version__,
lifespan=combine_lifespans(_lifespan, mcp_app.lifespan),
version=get_version(),
lifespan=combine_lifespans(*lifespans),
routes=[Mount(path='/mcp', app=mcp_app)],
)
@@ -82,5 +90,6 @@ app.include_router(settings_router)
app.include_router(secrets_router)
if server_config.app_mode == AppMode.OSS:
app.include_router(git_api_router)
app.include_router(v1_router.router)
app.include_router(trajectory_router)
add_health_endpoints(app)

View File

@@ -62,6 +62,7 @@ class DockerNestedConversationManager(ConversationManager):
async def __aenter__(self):
runtime_cls = get_runtime_cls(self.config.runtime)
runtime_cls.setup(self.config)
return self
async def __aexit__(self, exc_type, exc_value, traceback):
runtime_cls = get_runtime_cls(self.config.runtime)

View File

@@ -27,3 +27,4 @@ class ConversationInfo:
session_api_key: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
pr_number: list[int] = field(default_factory=list)
conversation_version: str = 'V0'

View File

@@ -1,14 +1,26 @@
import base64
import itertools
import json
import os
import re
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
import base62
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
from jinja2 import Environment, FileSystemLoader
from pydantic import BaseModel, ConfigDict, Field
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversation,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.config import (
depends_app_conversation_service,
)
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig
from openhands.core.logger import openhands_logger as logger
@@ -34,6 +46,7 @@ from openhands.integrations.service_types import (
)
from openhands.runtime import get_runtime_cls
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
from openhands.server.data_models.conversation_info import ConversationInfo
from openhands.server.data_models.conversation_info_result_set import (
@@ -76,6 +89,7 @@ from openhands.utils.async_utils import wait_all
from openhands.utils.conversation_summary import get_default_conversation_title
app = APIRouter(prefix='/api', dependencies=get_dependencies())
app_conversation_service_dependency = depends_app_conversation_service()
def _filter_conversations_by_age(
@@ -282,44 +296,150 @@ async def search_conversations(
selected_repository: str | None = None,
conversation_trigger: ConversationTrigger | None = None,
conversation_store: ConversationStore = Depends(get_conversation_store),
app_conversation_service: AppConversationService = app_conversation_service_dependency,
) -> ConversationInfoResultSet:
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
# Parse combined page_id to extract separate page_ids for each source
v0_page_id = None
v1_page_id = None
# Apply age filter first using common function
filtered_results = _filter_conversations_by_age(
conversation_metadata_result_set.results, config.conversation_max_age_seconds
if page_id:
try:
# Try to parse as JSON first
page_data = json.loads(base64.b64decode(page_id))
v0_page_id = page_data.get('v0')
v1_page_id = page_data.get('v1')
except (json.JSONDecodeError, TypeError):
# Fallback: treat as v0 page_id for backward compatibility
v0_page_id = page_id
# Get results from old conversation store (V0)
conversation_metadata_result_set = await conversation_store.search(
v0_page_id, limit
)
# Apply additional filters
final_filtered_results = []
for conversation in filtered_results:
# Apply repository filter
if (
selected_repository is not None
and conversation.selected_repository != selected_repository
):
continue
# Get results from new app conversation service (V1)
age_filter_date = None
if config.conversation_max_age_seconds:
age_filter_date = datetime.now(timezone.utc) - timedelta(
seconds=config.conversation_max_age_seconds
)
# Apply conversation trigger filter
if (
conversation_trigger is not None
and conversation.trigger != conversation_trigger
):
continue
final_filtered_results.append(conversation)
return await _build_conversation_result_set(
final_filtered_results, conversation_metadata_result_set.next_page_id
app_conversation_page = await app_conversation_service.search_app_conversations(
page_id=v1_page_id,
limit=limit,
# Apply age filter at the service level if possible
created_at__gte=age_filter_date,
)
# Convert V1 conversations to ConversationInfo format
v1_conversations = [
_to_conversation_info(app_conv) for app_conv in app_conversation_page.items
]
# Apply age filter to V0 conversations
v0_filtered_results = _filter_conversations_by_age(
conversation_metadata_result_set.results,
config.conversation_max_age_seconds,
)
v0_conversation_ids = set(
conversation.conversation_id for conversation in v0_filtered_results
)
await conversation_manager.get_connections(filter_to_sids=v0_conversation_ids)
v0_agent_loop_info = await conversation_manager.get_agent_loop_info(
filter_to_sids=v0_conversation_ids
)
v0_agent_loop_info_by_conversation_id = {
info.conversation_id: info for info in v0_agent_loop_info
}
v0_conversations = await wait_all(
_get_conversation_info(
conversation=conversation,
num_connections=sum(
1
for conversation_id in v0_agent_loop_info_by_conversation_id.values()
if conversation_id == conversation.conversation_id
),
agent_loop_info=v0_agent_loop_info_by_conversation_id.get(
conversation.conversation_id
),
)
for conversation in v0_filtered_results
)
# Apply additional filters to both V0 and V1 results
def apply_filters(conversations: list[ConversationInfo]) -> list[ConversationInfo]:
filtered = []
for conversation in conversations:
# Apply repository filter
if (
selected_repository is not None
and conversation.selected_repository != selected_repository
):
continue
# Apply conversation trigger filter
if (
conversation_trigger is not None
and conversation.trigger != conversation_trigger
):
continue
filtered.append(conversation)
return filtered
v0_final_filtered = apply_filters(v0_conversations)
v1_final_filtered = apply_filters(v1_conversations)
# Combine results from both sources
all_conversations = v0_final_filtered + v1_final_filtered
# Sort by created_at descending (most recent first)
all_conversations.sort(
key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
# Limit to requested number of results
final_results = all_conversations[:limit]
# Create combined page_id for next page
next_page_id = None
if (
conversation_metadata_result_set.next_page_id
or app_conversation_page.next_page_id
):
next_page_data = {
'v0': conversation_metadata_result_set.next_page_id,
'v1': app_conversation_page.next_page_id,
}
# Only include page_id if at least one source has more pages
if next_page_data['v0'] or next_page_data['v1']:
next_page_id = base64.b64encode(
json.dumps(next_page_data).encode()
).decode()
return ConversationInfoResultSet(results=final_results, next_page_id=next_page_id)
@app.get('/conversations/{conversation_id}')
async def get_conversation(
conversation_id: str = Depends(validate_conversation_id),
conversation_store: ConversationStore = Depends(get_conversation_store),
app_conversation_service: AppConversationService = app_conversation_service_dependency,
) -> ConversationInfo | None:
try:
# Shim to add V1 conversations
try:
conversation_uuid = uuid.UUID(conversation_id)
app_conversation = await app_conversation_service.get_app_conversation(
conversation_uuid
)
if app_conversation:
return _to_conversation_info(app_conversation)
except (ValueError, TypeError, Exception):
# Not a V1 conversation or service error
pass
metadata = await conversation_store.get_metadata(conversation_id)
num_connections = len(
await conversation_manager.get_connections(filter_to_sids={conversation_id})
@@ -833,3 +953,56 @@ async def get_microagent_management_conversations(
return await _build_conversation_result_set(
final_filtered_results, conversation_metadata_result_set.next_page_id
)
def _to_conversation_info(app_conversation: AppConversation) -> ConversationInfo:
"""Convert a V1 AppConversation into an old style ConversationInfo"""
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
# Map SandboxStatus to ConversationStatus
conversation_status_mapping = {
SandboxStatus.RUNNING: ConversationStatus.RUNNING,
SandboxStatus.STARTING: ConversationStatus.STARTING,
SandboxStatus.PAUSED: ConversationStatus.STOPPED,
SandboxStatus.ERROR: ConversationStatus.ERROR,
SandboxStatus.MISSING: ConversationStatus.ARCHIVED,
}
conversation_status = conversation_status_mapping.get(
app_conversation.sandbox_status, ConversationStatus.STOPPED
)
runtime_status_mapping = {
AgentExecutionStatus.ERROR: RuntimeStatus.ERROR,
AgentExecutionStatus.IDLE: RuntimeStatus.READY,
AgentExecutionStatus.RUNNING: RuntimeStatus.READY,
AgentExecutionStatus.PAUSED: RuntimeStatus.READY,
AgentExecutionStatus.WAITING_FOR_CONFIRMATION: RuntimeStatus.READY,
AgentExecutionStatus.FINISHED: RuntimeStatus.READY,
AgentExecutionStatus.STUCK: RuntimeStatus.ERROR,
}
runtime_status = runtime_status_mapping.get(
app_conversation.agent_status, RuntimeStatus.ERROR
)
title = (
app_conversation.title
or f'Conversation {base62.encodebytes(app_conversation.id.bytes)}'
)
return ConversationInfo(
conversation_id=str(app_conversation.id),
title=title,
last_updated_at=app_conversation.updated_at,
status=conversation_status,
runtime_status=runtime_status,
selected_repository=app_conversation.selected_repository,
selected_branch=app_conversation.selected_branch,
git_provider=app_conversation.git_provider,
trigger=app_conversation.trigger,
num_connections=0, # V1 conversations don't track connections the same way
url=app_conversation.conversation_url,
session_api_key=app_conversation.session_api_key,
created_at=app_conversation.created_at,
pr_number=app_conversation.pr_number,
conversation_version='V1',
)

View File

@@ -13,7 +13,6 @@ from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import GitService, ProviderType
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import ConversationStoreImpl, config, server_config
from openhands.server.types import AppMode
from openhands.server.user_auth import (
@@ -24,7 +23,7 @@ from openhands.server.user_auth import (
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
mcp_server = FastMCP(
'mcp', stateless_http=True, dependencies=get_dependencies(), mask_error_details=True
'mcp', stateless_http=True, mask_error_details=True, dependencies=None
)
HOST = f'https://{os.getenv("WEB_HOST", "app.all-hands.dev").strip()}'

View File

@@ -92,3 +92,8 @@ class DefaultUserAuth(UserAuth):
async def get_instance(cls, request: Request) -> UserAuth:
user_auth = DefaultUserAuth()
return user_auth
@classmethod
async def get_for_user(cls, user_id: str) -> UserAuth:
assert user_id == 'root'
return DefaultUserAuth()

View File

@@ -80,6 +80,11 @@ class UserAuth(ABC):
async def get_instance(cls, request: Request) -> UserAuth:
"""Get an instance of UserAuth from the request given"""
@classmethod
@abstractmethod
async def get_for_user(cls, user_id: str) -> UserAuth:
"""Get an instance of UserAuth for the user given"""
async def get_user_auth(request: Request) -> UserAuth:
user_auth: UserAuth | None = getattr(request.state, 'user_auth', None)
@@ -92,3 +97,10 @@ async def get_user_auth(request: Request) -> UserAuth:
raise ValueError('Failed to get user auth instance')
request.state.user_auth = user_auth
return user_auth
async def get_for_user(user_id: str) -> UserAuth:
impl_name = server_config.user_auth_class
impl = get_impl(UserAuth, impl_name)
user_auth = await impl.get_for_user(user_id)
return user_auth