mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-25 21:36:52 +08:00
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Graham Neubig <neubig@gmail.com> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
391 lines
16 KiB
Python
391 lines
16 KiB
Python
import asyncio
|
|
import time
|
|
from logging import LoggerAdapter
|
|
|
|
import socketio
|
|
|
|
from openhands.controller.agent import Agent
|
|
from openhands.core.config import OpenHandsConfig
|
|
from openhands.core.config.condenser_config import (
|
|
BrowserOutputCondenserConfig,
|
|
CondenserPipelineConfig,
|
|
ConversationWindowCondenserConfig,
|
|
LLMSummarizingCondenserConfig,
|
|
)
|
|
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
|
|
from openhands.core.exceptions import MicroagentValidationError
|
|
from openhands.core.logger import OpenHandsLoggerAdapter
|
|
from openhands.core.schema import AgentState
|
|
from openhands.events.action import MessageAction, NullAction
|
|
from openhands.events.event import Event, EventSource
|
|
from openhands.events.observation import (
|
|
AgentStateChangedObservation,
|
|
CmdOutputObservation,
|
|
NullObservation,
|
|
)
|
|
from openhands.events.observation.agent import RecallObservation
|
|
from openhands.events.observation.error import ErrorObservation
|
|
from openhands.events.serialization import event_from_dict, event_to_dict
|
|
from openhands.events.stream import EventStreamSubscriber
|
|
from openhands.llm.llm_registry import LLMRegistry
|
|
from openhands.runtime.runtime_status import RuntimeStatus
|
|
from openhands.server.constants import ROOM_KEY
|
|
from openhands.server.services.conversation_stats import ConversationStats
|
|
from openhands.server.session.agent_session import AgentSession
|
|
from openhands.server.session.conversation_init_data import ConversationInitData
|
|
from openhands.storage.data_models.settings import Settings
|
|
from openhands.storage.files import FileStore
|
|
|
|
|
|
class Session:
|
|
sid: str
|
|
sio: socketio.AsyncServer | None
|
|
last_active_ts: int = 0
|
|
is_alive: bool = True
|
|
agent_session: AgentSession
|
|
loop: asyncio.AbstractEventLoop
|
|
config: OpenHandsConfig
|
|
llm_registry: LLMRegistry
|
|
file_store: FileStore
|
|
user_id: str | None
|
|
logger: LoggerAdapter
|
|
|
|
def __init__(
|
|
self,
|
|
sid: str,
|
|
config: OpenHandsConfig,
|
|
llm_registry: LLMRegistry,
|
|
convo_stats: ConversationStats,
|
|
file_store: FileStore,
|
|
sio: socketio.AsyncServer | None,
|
|
user_id: str | None = None,
|
|
):
|
|
self.sid = sid
|
|
self.sio = sio
|
|
self.last_active_ts = int(time.time())
|
|
self.file_store = file_store
|
|
self.logger = OpenHandsLoggerAdapter(extra={'session_id': sid})
|
|
self.llm_registry = llm_registry
|
|
self.convo_stats = convo_stats
|
|
self.agent_session = AgentSession(
|
|
sid,
|
|
file_store,
|
|
llm_registry=self.llm_registry,
|
|
convo_stats=convo_stats,
|
|
status_callback=self.queue_status_message,
|
|
user_id=user_id,
|
|
)
|
|
self.agent_session.event_stream.subscribe(
|
|
EventStreamSubscriber.SERVER, self.on_event, self.sid
|
|
)
|
|
self.config = config
|
|
|
|
# Lazy import to avoid circular dependency
|
|
from openhands.experiments.experiment_manager import ExperimentManagerImpl
|
|
|
|
self.config = ExperimentManagerImpl.run_config_variant_test(
|
|
user_id, sid, self.config
|
|
)
|
|
self.loop = asyncio.get_event_loop()
|
|
self.user_id = user_id
|
|
|
|
async def close(self) -> None:
|
|
if self.sio:
|
|
await self.sio.emit(
|
|
'oh_event',
|
|
event_to_dict(
|
|
AgentStateChangedObservation('', AgentState.STOPPED.value)
|
|
),
|
|
to=ROOM_KEY.format(sid=self.sid),
|
|
)
|
|
self.is_alive = False
|
|
await self.agent_session.close()
|
|
|
|
async def initialize_agent(
|
|
self,
|
|
settings: Settings,
|
|
initial_message: MessageAction | None,
|
|
replay_json: str | None,
|
|
) -> None:
|
|
self.agent_session.event_stream.add_event(
|
|
AgentStateChangedObservation('', AgentState.LOADING),
|
|
EventSource.ENVIRONMENT,
|
|
)
|
|
agent_cls = settings.agent or self.config.default_agent
|
|
self.config.security.confirmation_mode = (
|
|
self.config.security.confirmation_mode
|
|
if settings.confirmation_mode is None
|
|
else settings.confirmation_mode
|
|
)
|
|
self.config.security.security_analyzer = (
|
|
settings.security_analyzer or self.config.security.security_analyzer
|
|
)
|
|
self.config.sandbox.base_container_image = (
|
|
settings.sandbox_base_container_image
|
|
or self.config.sandbox.base_container_image
|
|
)
|
|
self.config.sandbox.runtime_container_image = (
|
|
settings.sandbox_runtime_container_image
|
|
if settings.sandbox_base_container_image
|
|
or settings.sandbox_runtime_container_image
|
|
else self.config.sandbox.runtime_container_image
|
|
)
|
|
|
|
# Set Git user configuration if provided in settings
|
|
git_user_name = getattr(settings, 'git_user_name', None)
|
|
if git_user_name is not None:
|
|
self.config.git_user_name = git_user_name
|
|
git_user_email = getattr(settings, 'git_user_email', None)
|
|
if git_user_email is not None:
|
|
self.config.git_user_email = git_user_email
|
|
max_iterations = settings.max_iterations or self.config.max_iterations
|
|
|
|
# Prioritize settings over config for max_budget_per_task
|
|
max_budget_per_task = (
|
|
settings.max_budget_per_task
|
|
if settings.max_budget_per_task is not None
|
|
else self.config.max_budget_per_task
|
|
)
|
|
|
|
self.config.search_api_key = settings.search_api_key
|
|
if settings.sandbox_api_key:
|
|
self.config.sandbox.api_key = settings.sandbox_api_key.get_secret_value()
|
|
|
|
# NOTE: this need to happen AFTER the config is updated with the search_api_key
|
|
self.logger.debug(
|
|
f'MCP configuration before setup - self.config.mcp_config: {self.config.mcp}'
|
|
)
|
|
|
|
# Check if settings has custom mcp_config
|
|
mcp_config = getattr(settings, 'mcp_config', None)
|
|
if mcp_config is not None:
|
|
# Use the provided MCP SHTTP servers instead of default setup
|
|
self.config.mcp = self.config.mcp.merge(mcp_config)
|
|
self.logger.debug(f'Merged custom MCP Config: {mcp_config}')
|
|
|
|
# Add OpenHands' MCP server by default
|
|
openhands_mcp_server, openhands_mcp_stdio_servers = (
|
|
OpenHandsMCPConfigImpl.create_default_mcp_server_config(
|
|
self.config.mcp_host, self.config, self.user_id
|
|
)
|
|
)
|
|
|
|
if openhands_mcp_server:
|
|
self.config.mcp.shttp_servers.append(openhands_mcp_server)
|
|
self.logger.debug('Added default MCP HTTP server to config')
|
|
|
|
self.config.mcp.stdio_servers.extend(openhands_mcp_stdio_servers)
|
|
|
|
self.logger.debug(
|
|
f'MCP configuration after setup - self.config.mcp: {self.config.mcp}'
|
|
)
|
|
|
|
# TODO: override other LLM config & agent config groups (#2075)
|
|
agent_config = self.config.get_agent_config(agent_cls)
|
|
agent_name = agent_cls if agent_cls is not None else 'agent'
|
|
llm_config = self.config.get_llm_config_from_agent(agent_name)
|
|
if settings.enable_default_condenser:
|
|
# Default condenser chains three condensers together:
|
|
# 1. a conversation window condenser that handles explicit
|
|
# condensation requests,
|
|
# 2. a condenser that limits the total size of browser observations,
|
|
# and
|
|
# 3. a condenser that limits the size of the view given to the LLM.
|
|
# The order matters: with the browser output first, the summarizer
|
|
# will only see the most recent browser output, which should keep
|
|
# the summarization cost down.
|
|
default_condenser_config = CondenserPipelineConfig(
|
|
condensers=[
|
|
ConversationWindowCondenserConfig(),
|
|
BrowserOutputCondenserConfig(attention_window=2),
|
|
LLMSummarizingCondenserConfig(
|
|
llm_config=llm_config, keep_first=4, max_size=120
|
|
),
|
|
]
|
|
)
|
|
|
|
self.logger.info(
|
|
f'Enabling pipeline condenser with:'
|
|
f' browser_output_masking(attention_window=2), '
|
|
f' llm(model="{llm_config.model}", '
|
|
f' base_url="{llm_config.base_url}", '
|
|
f' keep_first=4, max_size=80)'
|
|
)
|
|
agent_config.condenser = default_condenser_config
|
|
agent = Agent.get_cls(agent_cls)(agent_config, self.llm_registry)
|
|
|
|
self.llm_registry.retry_listner = self._notify_on_llm_retry
|
|
|
|
git_provider_tokens = None
|
|
selected_repository = None
|
|
selected_branch = None
|
|
custom_secrets = None
|
|
conversation_instructions = None
|
|
if isinstance(settings, ConversationInitData):
|
|
git_provider_tokens = settings.git_provider_tokens
|
|
selected_repository = settings.selected_repository
|
|
selected_branch = settings.selected_branch
|
|
custom_secrets = settings.custom_secrets
|
|
conversation_instructions = settings.conversation_instructions
|
|
|
|
try:
|
|
await self.agent_session.start(
|
|
runtime_name=self.config.runtime,
|
|
config=self.config,
|
|
agent=agent,
|
|
max_iterations=max_iterations,
|
|
max_budget_per_task=max_budget_per_task,
|
|
agent_to_llm_config=self.config.get_agent_to_llm_config_map(),
|
|
agent_configs=self.config.get_agent_configs(),
|
|
git_provider_tokens=git_provider_tokens,
|
|
custom_secrets=custom_secrets,
|
|
selected_repository=selected_repository,
|
|
selected_branch=selected_branch,
|
|
initial_message=initial_message,
|
|
conversation_instructions=conversation_instructions,
|
|
replay_json=replay_json,
|
|
)
|
|
except MicroagentValidationError as e:
|
|
self.logger.exception(f'Error creating agent_session: {e}')
|
|
# For microagent validation errors, provide more helpful information
|
|
await self.send_error(f'Failed to create agent session: {str(e)}')
|
|
return
|
|
except ValueError as e:
|
|
self.logger.exception(f'Error creating agent_session: {e}')
|
|
error_message = str(e)
|
|
# For ValueError related to microagents, provide more helpful information
|
|
if 'microagent' in error_message.lower():
|
|
await self.send_error(
|
|
f'Failed to create agent session: {error_message}'
|
|
)
|
|
else:
|
|
# For other ValueErrors, just show the error class
|
|
await self.send_error('Failed to create agent session: ValueError')
|
|
return
|
|
except Exception as e:
|
|
self.logger.exception(f'Error creating agent_session: {e}')
|
|
# For other errors, just show the error class to avoid exposing sensitive information
|
|
await self.send_error(
|
|
f'Failed to create agent session: {e.__class__.__name__}'
|
|
)
|
|
return
|
|
|
|
def _notify_on_llm_retry(self, retries: int, max: int) -> None:
|
|
self.queue_status_message(
|
|
'info', RuntimeStatus.LLM_RETRY, f'Retrying LLM request, {retries} / {max}'
|
|
)
|
|
|
|
def on_event(self, event: Event) -> None:
|
|
asyncio.get_event_loop().run_until_complete(self._on_event(event))
|
|
|
|
async def _on_event(self, event: Event) -> None:
|
|
"""Callback function for events that mainly come from the agent.
|
|
|
|
Event is the base class for any agent action and observation.
|
|
|
|
Args:
|
|
event: The agent event (Observation or Action).
|
|
"""
|
|
if isinstance(event, NullAction):
|
|
return
|
|
if isinstance(event, NullObservation):
|
|
return
|
|
if event.source == EventSource.AGENT:
|
|
await self.send(event_to_dict(event))
|
|
elif event.source == EventSource.USER:
|
|
await self.send(event_to_dict(event))
|
|
# NOTE: ipython observations are not sent here currently
|
|
elif event.source == EventSource.ENVIRONMENT and isinstance(
|
|
event,
|
|
(CmdOutputObservation, AgentStateChangedObservation, RecallObservation),
|
|
):
|
|
# feedback from the environment to agent actions is understood as agent events by the UI
|
|
event_dict = event_to_dict(event)
|
|
event_dict['source'] = EventSource.AGENT
|
|
await self.send(event_dict)
|
|
if (
|
|
isinstance(event, AgentStateChangedObservation)
|
|
and event.agent_state == AgentState.ERROR
|
|
):
|
|
self.logger.error(
|
|
f'Agent status error: {event.reason}',
|
|
extra={'signal': 'agent_status_error'},
|
|
)
|
|
elif isinstance(event, ErrorObservation):
|
|
# send error events as agent events to the UI
|
|
event_dict = event_to_dict(event)
|
|
event_dict['source'] = EventSource.AGENT
|
|
await self.send(event_dict)
|
|
|
|
async def dispatch(self, data: dict) -> None:
|
|
event = event_from_dict(data.copy())
|
|
# This checks if the model supports images
|
|
if isinstance(event, MessageAction) and event.image_urls:
|
|
controller = self.agent_session.controller
|
|
if controller:
|
|
if controller.agent.llm.config.disable_vision:
|
|
await self.send_error(
|
|
'Support for images is disabled for this model, try without an image.'
|
|
)
|
|
return
|
|
if not controller.agent.llm.vision_is_active():
|
|
await self.send_error(
|
|
'Model does not support image upload, change to a different model or try without an image.'
|
|
)
|
|
return
|
|
self.agent_session.event_stream.add_event(event, EventSource.USER)
|
|
|
|
async def send(self, data: dict[str, object]) -> None:
|
|
if asyncio.get_running_loop() != self.loop:
|
|
self.loop.create_task(self._send(data))
|
|
return
|
|
await self._send(data)
|
|
|
|
async def _send(self, data: dict[str, object]) -> bool:
|
|
try:
|
|
if not self.is_alive:
|
|
return False
|
|
if self.sio:
|
|
await self.sio.emit('oh_event', data, to=ROOM_KEY.format(sid=self.sid))
|
|
await asyncio.sleep(0.001) # This flushes the data to the client
|
|
self.last_active_ts = int(time.time())
|
|
return True
|
|
except RuntimeError as e:
|
|
self.logger.error(f'Error sending data to websocket: {str(e)}')
|
|
self.is_alive = False
|
|
return False
|
|
|
|
async def send_error(self, message: str) -> None:
|
|
"""Sends an error message to the client."""
|
|
await self.send({'error': True, 'message': message})
|
|
|
|
async def _send_status_message(
|
|
self, msg_type: str, runtime_status: RuntimeStatus, message: str
|
|
) -> None:
|
|
"""Sends a status message to the client."""
|
|
if msg_type == 'error':
|
|
agent_session = self.agent_session
|
|
controller = self.agent_session.controller
|
|
if controller is not None and not agent_session.is_closed():
|
|
await controller.set_agent_state_to(AgentState.ERROR)
|
|
self.logger.error(
|
|
f'Agent status error: {message}',
|
|
extra={'signal': 'agent_status_error'},
|
|
)
|
|
await self.send(
|
|
{
|
|
'status_update': True,
|
|
'type': msg_type,
|
|
'id': runtime_status.value,
|
|
'message': message,
|
|
}
|
|
)
|
|
|
|
def queue_status_message(
|
|
self, msg_type: str, runtime_status: RuntimeStatus, message: str
|
|
) -> None:
|
|
"""Queues a status message to be sent asynchronously."""
|
|
asyncio.run_coroutine_threadsafe(
|
|
self._send_status_message(msg_type, runtime_status, message), self.loop
|
|
)
|