OpenHands / openhands /server /conversation_manager /standalone_conversation_manager.py
Backup-bdg's picture
Upload 964 files
51ff9e5 verified
import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable, Iterable
import socketio
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.stream import EventStreamSubscriber, session_exists
from openhands.server.config.server_config import ServerConfig
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
from openhands.server.monitoring import MonitoringListener
from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE
from openhands.server.session.conversation import ServerConversation
from openhands.server.session.session import ROOM_KEY, Session
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync, wait_all
from openhands.utils.conversation_summary import (
auto_generate_title,
get_default_conversation_title,
)
from openhands.utils.import_utils import get_impl
from openhands.utils.shutdown_listener import should_continue
from .conversation_manager import ConversationManager
_CLEANUP_INTERVAL = 15
UPDATED_AT_CALLBACK_ID = 'updated_at_callback_id'
@dataclass
class StandaloneConversationManager(ConversationManager):
"""Default implementation of ConversationManager for single-server deployments.
See ConversationManager for extensibility details.
"""
sio: socketio.AsyncServer
config: OpenHandsConfig
file_store: FileStore
server_config: ServerConfig
# Defaulting monitoring_listener for temp backward compatibility.
monitoring_listener: MonitoringListener = MonitoringListener()
_local_agent_loops_by_sid: dict[str, Session] = field(default_factory=dict)
_local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
_active_conversations: dict[str, tuple[ServerConversation, int]] = field(
default_factory=dict
)
_detached_conversations: dict[str, tuple[ServerConversation, float]] = field(
default_factory=dict
)
_conversations_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_cleanup_task: asyncio.Task | None = None
_conversation_store_class: type[ConversationStore] | None = None
async def __aenter__(self):
self._cleanup_task = asyncio.create_task(self._cleanup_stale())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._cleanup_task:
self._cleanup_task.cancel()
self._cleanup_task = None
async def attach_to_conversation(
self, sid: str, user_id: str | None = None
) -> ServerConversation | None:
start_time = time.time()
if not await session_exists(sid, self.file_store, user_id=user_id):
return None
async with self._conversations_lock:
# Check if we have an active conversation we can reuse
if sid in self._active_conversations:
conversation, count = self._active_conversations[sid]
self._active_conversations[sid] = (conversation, count + 1)
logger.info(
f'Reusing active conversation {sid}', extra={'session_id': sid}
)
return conversation
# Check if we have a detached conversation we can reuse
if sid in self._detached_conversations:
conversation, _ = self._detached_conversations.pop(sid)
self._active_conversations[sid] = (conversation, 1)
logger.info(
f'Reusing detached conversation {sid}', extra={'session_id': sid}
)
return conversation
# Create new conversation if none exists
c = ServerConversation(
sid, file_store=self.file_store, config=self.config, user_id=user_id
)
try:
await c.connect()
except AgentRuntimeUnavailableError as e:
logger.error(
f'Error connecting to conversation {c.sid}: {e}',
extra={'session_id': sid},
)
await c.disconnect()
return None
end_time = time.time()
logger.info(
f'ServerConversation {c.sid} connected in {end_time - start_time} seconds',
extra={'session_id': sid}
)
self._active_conversations[sid] = (c, 1)
return c
async def join_conversation(
self,
sid: str,
connection_id: str,
settings: Settings,
user_id: str | None,
) -> AgentLoopInfo:
logger.info(
f'join_conversation:{sid}:{connection_id}',
extra={'session_id': sid, 'user_id': user_id},
)
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self._local_connection_id_to_session_id[connection_id] = sid
agent_loop_info = await self.maybe_start_agent_loop(sid, settings, user_id)
return agent_loop_info
async def detach_from_conversation(self, conversation: ServerConversation):
sid = conversation.sid
async with self._conversations_lock:
if sid in self._active_conversations:
conv, count = self._active_conversations[sid]
if count > 1:
self._active_conversations[sid] = (conv, count - 1)
return
else:
self._active_conversations.pop(sid)
self._detached_conversations[sid] = (conversation, time.time())
async def _cleanup_stale(self):
while should_continue():
try:
async with self._conversations_lock:
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._detached_conversations.items())
for sid, (conversation, detach_time) in items:
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
# Implies disconnected sandboxes stay open indefinitely
if not self.config.sandbox.close_delay:
return
close_threshold = time.time() - self.config.sandbox.close_delay
running_loops = list(self._local_agent_loops_by_sid.items())
running_loops.sort(key=lambda item: item[1].last_active_ts)
sid_to_close: list[str] = []
for sid, session in running_loops:
state = session.agent_session.get_state()
if session.last_active_ts < close_threshold and state not in [
AgentState.RUNNING,
None,
]:
sid_to_close.append(sid)
connections = await self.get_connections(
filter_to_sids=set(sid_to_close) # get_connections expects a set
)
connected_sids = {sid for _, sid in connections.items()}
sid_to_close = [
sid for sid in sid_to_close if sid not in connected_sids
]
await wait_all(
(self._close_session(sid) for sid in sid_to_close),
timeout=WAIT_TIME_BEFORE_CLOSE,
)
await asyncio.sleep(_CLEANUP_INTERVAL)
except asyncio.CancelledError:
async with self._conversations_lock:
for conversation, _ in self._detached_conversations.values():
await conversation.disconnect()
self._detached_conversations.clear()
await wait_all(
self._close_session(sid) for sid in self._local_agent_loops_by_sid
)
return
except Exception:
logger.error('error_cleaning_stale')
await asyncio.sleep(_CLEANUP_INTERVAL)
async def _get_conversation_store(self, user_id: str | None) -> ConversationStore:
conversation_store_class = self._conversation_store_class
if not conversation_store_class:
self._conversation_store_class = conversation_store_class = get_impl(
ConversationStore,
self.server_config.conversation_store_class,
)
store = await conversation_store_class.get_instance(self.config, user_id)
return store
async def get_running_agent_loops(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
"""Get the running session ids in chronological order (oldest first).
If a user is supplied, then the results are limited to session ids for that user.
If a set of filter_to_sids is supplied, then results are limited to these ids of interest.
Returns:
A set of session IDs
"""
# Get all items and convert to list for sorting
items: Iterable[tuple[str, Session]] = self._local_agent_loops_by_sid.items()
# Filter items if needed
if filter_to_sids is not None:
items = (item for item in items if item[0] in filter_to_sids)
if user_id:
items = (item for item in items if item[1].user_id == user_id)
sids = {sid for sid, _ in items}
return sids
async def get_connections(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> dict[str, str]:
connections = dict(**self._local_connection_id_to_session_id)
if filter_to_sids is not None:
connections = {
connection_id: sid
for connection_id, sid in connections.items()
if sid in filter_to_sids
}
if user_id:
for connection_id, sid in list(connections.items()):
session = self._local_agent_loops_by_sid.get(sid)
if not session or session.user_id != user_id:
connections.pop(connection_id)
return connections
async def maybe_start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
replay_json: str | None = None,
) -> AgentLoopInfo:
logger.info(f'maybe_start_agent_loop:{sid}', extra={'session_id': sid})
session = self._local_agent_loops_by_sid.get(sid)
if not session:
session = await self._start_agent_loop(
sid, settings, user_id, initial_user_msg, replay_json
)
return self._agent_loop_info_from_session(session)
async def _start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
replay_json: str | None = None,
) -> Session:
logger.info(f'starting_agent_loop:{sid}', extra={'session_id': sid})
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) >= self.config.max_concurrent_conversations:
logger.info(
f'too_many_sessions_for:{user_id or ""}',
extra={'session_id': sid, 'user_id': user_id},
)
# Get the conversations sorted (oldest first)
conversation_store = await self._get_conversation_store(user_id)
conversations = await conversation_store.get_all_metadata(response_ids)
conversations.sort(key=_last_updated_at_key, reverse=True)
while len(conversations) >= self.config.max_concurrent_conversations:
oldest_conversation_id = conversations.pop().conversation_id
logger.debug(
f'closing_from_too_many_sessions:{user_id or ""}:{oldest_conversation_id}',
extra={'session_id': oldest_conversation_id, 'user_id': user_id},
)
# Send status message to client and close session.
status_update_dict = {
'status_update': True,
'type': 'error',
'id': 'AGENT_ERROR$TOO_MANY_CONVERSATIONS',
'message': 'Too many conversations at once. If you are still using this one, try reactivating it by prompting the agent to continue',
}
await self.sio.emit(
'oh_event',
status_update_dict,
to=ROOM_KEY.format(sid=oldest_conversation_id),
)
await self.close_session(oldest_conversation_id)
session = Session(
sid=sid,
file_store=self.file_store,
config=self.config,
sio=self.sio,
user_id=user_id,
)
self._local_agent_loops_by_sid[sid] = session
asyncio.create_task(
session.initialize_agent(settings, initial_user_msg, replay_json)
)
# This does not get added when resuming an existing conversation
try:
session.agent_session.event_stream.subscribe(
EventStreamSubscriber.SERVER,
self._create_conversation_update_callback(user_id, sid, settings),
UPDATED_AT_CALLBACK_ID,
)
except ValueError:
pass # Already subscribed - take no action
return session
async def send_to_event_stream(self, connection_id: str, data: dict):
# If there is a local session running, send to that
sid = self._local_connection_id_to_session_id.get(connection_id)
if not sid:
raise RuntimeError(f'no_connected_session:{connection_id}')
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data)
return
raise RuntimeError(f'no_connected_session:{connection_id}:{sid}')
async def disconnect_from_session(self, connection_id: str):
sid = self._local_connection_id_to_session_id.pop(connection_id, None)
logger.info(
f'disconnect_from_session:{connection_id}:{sid}', extra={'session_id': sid}
)
if not sid:
# This can occur if the init action was never run.
logger.warning(
f'disconnect_from_uninitialized_session:{connection_id}',
extra={'session_id': sid},
)
return
async def close_session(self, sid: str):
session = self._local_agent_loops_by_sid.get(sid)
if session:
await self._close_session(sid)
async def _close_session(self, sid: str):
logger.info(f'_close_session:{sid}', extra={'session_id': sid})
# Clear up local variables
connection_ids_to_remove = list(
connection_id
for connection_id, conn_sid in self._local_connection_id_to_session_id.items()
if sid == conn_sid
)
logger.info(
f'removing connections: {connection_ids_to_remove}',
extra={'session_id': sid},
)
# Perform a graceful shutdown of each connection
for connection_id in connection_ids_to_remove:
await self.sio.disconnect(connection_id)
self._local_connection_id_to_session_id.pop(connection_id, None)
session = self._local_agent_loops_by_sid.pop(sid, None)
if not session:
logger.warning(f'no_session_to_close:{sid}', extra={'session_id': sid})
return
logger.info(f'closing_session:{session.sid}', extra={'session_id': sid})
await session.close()
logger.info(f'closed_session:{session.sid}', extra={'session_id': sid})
@classmethod
def get_instance(
cls,
sio: socketio.AsyncServer,
config: OpenHandsConfig,
file_store: FileStore,
server_config: ServerConfig,
monitoring_listener: MonitoringListener | None,
) -> ConversationManager:
return StandaloneConversationManager(
sio,
config,
file_store,
server_config,
monitoring_listener or MonitoringListener(),
)
def _create_conversation_update_callback(
self,
user_id: str | None,
conversation_id: str,
settings: Settings,
) -> Callable:
def callback(event, *args, **kwargs):
call_async_from_sync(
self._update_conversation_for_event,
GENERAL_TIMEOUT,
user_id,
conversation_id,
settings,
event,
)
return callback
async def _update_conversation_for_event(
self,
user_id: str,
conversation_id: str,
settings: Settings,
event=None,
):
conversation_store = await self._get_conversation_store(user_id)
conversation = await conversation_store.get_metadata(conversation_id)
conversation.last_updated_at = datetime.now(timezone.utc)
# Update cost/token metrics if event has llm_metrics
if event and hasattr(event, 'llm_metrics') and event.llm_metrics:
metrics = event.llm_metrics
# Update accumulated cost
if hasattr(metrics, 'accumulated_cost'):
conversation.accumulated_cost = metrics.accumulated_cost
# Update token usage
if hasattr(metrics, 'accumulated_token_usage'):
token_usage = metrics.accumulated_token_usage
conversation.prompt_tokens = token_usage.prompt_tokens
conversation.completion_tokens = token_usage.completion_tokens
conversation.total_tokens = (
token_usage.prompt_tokens + token_usage.completion_tokens
)
default_title = get_default_conversation_title(conversation_id)
if (
conversation.title == default_title
): # attempt to autogenerate if default title is in use
title = await auto_generate_title(
conversation_id, user_id, self.file_store, settings
)
if title and not title.isspace():
conversation.title = title
try:
# Emit a status update to the client with the new title
status_update_dict = {
'status_update': True,
'type': 'info',
'message': conversation_id,
'conversation_title': conversation.title,
}
await self.sio.emit(
'oh_event',
status_update_dict,
to=ROOM_KEY.format(sid=conversation_id),
)
except Exception as e:
logger.error(f'Error emitting title update event: {e}')
else:
conversation.title = default_title
await conversation_store.save_metadata(conversation)
async def get_agent_loop_info(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
):
results = []
for session in self._local_agent_loops_by_sid.values():
if user_id and session.user_id != user_id:
continue
if filter_to_sids and session.sid not in filter_to_sids:
continue
results.append(self._agent_loop_info_from_session(session))
return results
def _agent_loop_info_from_session(self, session: Session):
return AgentLoopInfo(
conversation_id=session.sid,
url=self._get_conversation_url(session.sid),
session_api_key=None,
event_store=session.agent_session.event_stream,
)
def _get_conversation_url(self, conversation_id: str):
return f'/api/conversations/{conversation_id}'
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
last_updated_at = conversation.last_updated_at
if last_updated_at is None:
return 0.0
return last_updated_at.timestamp()