mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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()}'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user