From 6e089619e072fee884e9a9763b8c6ee54d4824b9 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 16 Jan 2025 00:37:42 +0100
Subject: [PATCH 01/39] chore(deps-dev): bump chromadb from 0.6.2 to 0.6.3 in
 the chromadb group (#6289)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 poetry.lock | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/poetry.lock b/poetry.lock
index f0b3719b2b9e..3cd9f4706f2e 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -927,13 +927,13 @@ numpy = "*"
 
 [[package]]
 name = "chromadb"
-version = "0.6.2"
+version = "0.6.3"
 description = "Chroma."
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "chromadb-0.6.2-py3-none-any.whl", hash = "sha256:77a5e07097e36cdd49d8d2925d0c4d28291cabc9677787423d2cc7c426e8895b"},
-    {file = "chromadb-0.6.2.tar.gz", hash = "sha256:e9e11f04d3850796711ee05dad4e918c75ec7b62ab9cbe7b4588b68a26aaea06"},
+    {file = "chromadb-0.6.3-py3-none-any.whl", hash = "sha256:4851258489a3612b558488d98d09ae0fe0a28d5cad6bd1ba64b96fdc419dc0e5"},
+    {file = "chromadb-0.6.3.tar.gz", hash = "sha256:c8f34c0b704b9108b04491480a36d42e894a960429f87c6516027b5481d59ed3"},
 ]
 
 [package.dependencies]

From efe04baf34c81ccbe5a1c2e17ea12b45d504bec5 Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Thu, 16 Jan 2025 09:14:56 -0500
Subject: [PATCH 02/39] Revert "Fix closing sessions" (#6300)

---
 openhands/core/config/sandbox_config.py       |   4 +-
 openhands/runtime/builder/remote.py           |   7 +-
 .../action_execution_client.py                |   3 +-
 openhands/runtime/utils/request.py            |   7 +-
 .../server/routes/manage_conversations.py     |   2 +-
 openhands/server/session/agent_session.py     |  33 +-
 openhands/server/session/manager.py           | 382 +++++++-----------
 openhands/server/session/session.py           |  12 +-
 openhands/utils/http_session.py               |  24 --
 tests/unit/test_manager.py                    |  78 ++--
 10 files changed, 221 insertions(+), 331 deletions(-)
 delete mode 100644 openhands/utils/http_session.py

diff --git a/openhands/core/config/sandbox_config.py b/openhands/core/config/sandbox_config.py
index 0ea40f29faab..3a0b705dd02d 100644
--- a/openhands/core/config/sandbox_config.py
+++ b/openhands/core/config/sandbox_config.py
@@ -41,7 +41,7 @@ class SandboxConfig:
 
     remote_runtime_api_url: str = 'http://localhost:8000'
     local_runtime_url: str = 'http://localhost'
-    keep_runtime_alive: bool = False
+    keep_runtime_alive: bool = True
     rm_all_containers: bool = False
     api_key: str | None = None
     base_container_image: str = 'nikolaik/python-nodejs:python3.12-nodejs22'  # default to nikolaik/python-nodejs:python3.12-nodejs22 for eventstream runtime
@@ -60,7 +60,7 @@ class SandboxConfig:
     runtime_startup_env_vars: dict[str, str] = field(default_factory=dict)
     browsergym_eval_env: str | None = None
     platform: str | None = None
-    close_delay: int = 15
+    close_delay: int = 900
     remote_runtime_resource_factor: int = 1
     enable_gpu: bool = False
     docker_runtime_kwargs: str | None = None
diff --git a/openhands/runtime/builder/remote.py b/openhands/runtime/builder/remote.py
index a728460a374e..b2e869eca3bf 100644
--- a/openhands/runtime/builder/remote.py
+++ b/openhands/runtime/builder/remote.py
@@ -9,7 +9,6 @@
 from openhands.core.logger import openhands_logger as logger
 from openhands.runtime.builder import RuntimeBuilder
 from openhands.runtime.utils.request import send_request
-from openhands.utils.http_session import HttpSession
 from openhands.utils.shutdown_listener import (
     should_continue,
     sleep_if_should_continue,
@@ -19,10 +18,12 @@
 class RemoteRuntimeBuilder(RuntimeBuilder):
     """This class interacts with the remote Runtime API for building and managing container images."""
 
-    def __init__(self, api_url: str, api_key: str, session: HttpSession | None = None):
+    def __init__(
+        self, api_url: str, api_key: str, session: requests.Session | None = None
+    ):
         self.api_url = api_url
         self.api_key = api_key
-        self.session = session or HttpSession()
+        self.session = session or requests.Session()
         self.session.headers.update({'X-API-Key': self.api_key})
 
     def build(
diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py
index 4965fc1752af..24fb8250b30e 100644
--- a/openhands/runtime/impl/action_execution/action_execution_client.py
+++ b/openhands/runtime/impl/action_execution/action_execution_client.py
@@ -35,7 +35,6 @@
 from openhands.runtime.base import Runtime
 from openhands.runtime.plugins import PluginRequirement
 from openhands.runtime.utils.request import send_request
-from openhands.utils.http_session import HttpSession
 
 
 class ActionExecutionClient(Runtime):
@@ -56,7 +55,7 @@ def __init__(
         attach_to_existing: bool = False,
         headless_mode: bool = True,
     ):
-        self.session = HttpSession()
+        self.session = requests.Session()
         self.action_semaphore = threading.Semaphore(1)  # Ensure one action at a time
         self._runtime_initialized: bool = False
         self._vscode_token: str | None = None  # initial dummy value
diff --git a/openhands/runtime/utils/request.py b/openhands/runtime/utils/request.py
index 0117e019a6a8..e05a083e7b0d 100644
--- a/openhands/runtime/utils/request.py
+++ b/openhands/runtime/utils/request.py
@@ -4,7 +4,6 @@
 import requests
 from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
 
-from openhands.utils.http_session import HttpSession
 from openhands.utils.tenacity_stop import stop_if_should_exit
 
 
@@ -35,7 +34,7 @@ def is_retryable_error(exception):
     wait=wait_exponential(multiplier=1, min=4, max=60),
 )
 def send_request(
-    session: HttpSession,
+    session: requests.Session,
     method: str,
     url: str,
     timeout: int = 10,
@@ -49,11 +48,11 @@ def send_request(
             _json = response.json()
         except (requests.exceptions.JSONDecodeError, json.decoder.JSONDecodeError):
             _json = None
+        finally:
+            response.close()
         raise RequestHTTPError(
             e,
             response=e.response,
             detail=_json.get('detail') if _json is not None else None,
         ) from e
-    finally:
-        response.close()
     return response
diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py
index f622c5bad9cf..5cfc0ba82d52 100644
--- a/openhands/server/routes/manage_conversations.py
+++ b/openhands/server/routes/manage_conversations.py
@@ -130,7 +130,7 @@ async def search_conversations(
         for conversation in conversation_metadata_result_set.results
         if hasattr(conversation, 'created_at')
     )
-    running_conversations = await session_manager.get_running_agent_loops(
+    running_conversations = await session_manager.get_agent_loop_running(
         get_user_id(request), set(conversation_ids)
     )
     result = ConversationInfoResultSet(
diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py
index 285acccbfbe4..70bf6eeca6bb 100644
--- a/openhands/server/session/agent_session.py
+++ b/openhands/server/session/agent_session.py
@@ -1,5 +1,4 @@
 import asyncio
-import time
 from typing import Callable, Optional
 
 from openhands.controller import AgentController
@@ -17,10 +16,10 @@
 from openhands.runtime.base import Runtime
 from openhands.security import SecurityAnalyzer, options
 from openhands.storage.files import FileStore
-from openhands.utils.async_utils import call_sync_from_async
+from openhands.utils.async_utils import call_async_from_sync, call_sync_from_async
 from openhands.utils.shutdown_listener import should_continue
 
-WAIT_TIME_BEFORE_CLOSE = 90
+WAIT_TIME_BEFORE_CLOSE = 300
 WAIT_TIME_BEFORE_CLOSE_INTERVAL = 5
 
 
@@ -37,8 +36,7 @@ class AgentSession:
     controller: AgentController | None = None
     runtime: Runtime | None = None
     security_analyzer: SecurityAnalyzer | None = None
-    _starting: bool = False
-    _started_at: float = 0
+    _initializing: bool = False
     _closed: bool = False
     loop: asyncio.AbstractEventLoop | None = None
 
@@ -90,8 +88,7 @@ async def start(
         if self._closed:
             logger.warning('Session closed before starting')
             return
-        self._starting = True
-        self._started_at = time.time()
+        self._initializing = True
         self._create_security_analyzer(config.security.security_analyzer)
         await self._create_runtime(
             runtime_name=runtime_name,
@@ -112,19 +109,24 @@ async def start(
         self.event_stream.add_event(
             ChangeAgentStateAction(AgentState.INIT), EventSource.ENVIRONMENT
         )
-        self._starting = False
+        self._initializing = False
 
-    async def close(self):
+    def close(self):
         """Closes the Agent session"""
         if self._closed:
             return
         self._closed = True
-        while self._starting and should_continue():
+        call_async_from_sync(self._close)
+
+    async def _close(self):
+        seconds_waited = 0
+        while self._initializing and should_continue():
             logger.debug(
                 f'Waiting for initialization to finish before closing session {self.sid}'
             )
             await asyncio.sleep(WAIT_TIME_BEFORE_CLOSE_INTERVAL)
-            if time.time() <= self._started_at + WAIT_TIME_BEFORE_CLOSE:
+            seconds_waited += WAIT_TIME_BEFORE_CLOSE_INTERVAL
+            if seconds_waited > WAIT_TIME_BEFORE_CLOSE:
                 logger.error(
                     f'Waited too long for initialization to finish before closing session {self.sid}'
                 )
@@ -309,12 +311,3 @@ def _maybe_restore_state(self) -> State | None:
             else:
                 logger.debug('No events found, no state to restore')
         return restored_state
-
-    def get_state(self) -> AgentState | None:
-        controller = self.controller
-        if controller:
-            return controller.state.agent_state
-        if time.time() > self._started_at + WAIT_TIME_BEFORE_CLOSE:
-            # If 5 minutes have elapsed and we still don't have a controller, something has gone wrong
-            return AgentState.ERROR
-        return None
diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py
index 3c4d929a72de..67358f61fbe8 100644
--- a/openhands/server/session/manager.py
+++ b/openhands/server/session/manager.py
@@ -2,7 +2,6 @@
 import json
 import time
 from dataclasses import dataclass, field
-from typing import Generic, Iterable, TypeVar
 from uuid import uuid4
 
 import socketio
@@ -10,28 +9,26 @@
 from openhands.core.config import AppConfig
 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.stream import EventStream, session_exists
 from openhands.server.session.conversation import Conversation
 from openhands.server.session.session import ROOM_KEY, Session
 from openhands.server.settings import Settings
 from openhands.storage.files import FileStore
-from openhands.utils.async_utils import wait_all
+from openhands.utils.async_utils import call_sync_from_async
 from openhands.utils.shutdown_listener import should_continue
 
 _REDIS_POLL_TIMEOUT = 1.5
 _CHECK_ALIVE_INTERVAL = 15
 
 _CLEANUP_INTERVAL = 15
-MAX_RUNNING_CONVERSATIONS = 3
-T = TypeVar('T')
+_CLEANUP_EXCEPTION_WAIT_TIME = 15
 
 
 @dataclass
-class _ClusterQuery(Generic[T]):
-    query_id: str
-    request_ids: set[str] | None
-    result: T
+class _SessionIsRunningCheck:
+    request_id: str
+    request_sids: list[str]
+    running_sids: set[str] = field(default_factory=set)
     flag: asyncio.Event = field(default_factory=asyncio.Event)
 
 
@@ -41,10 +38,10 @@ class SessionManager:
     config: AppConfig
     file_store: FileStore
     _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)
+    local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
     _last_alive_timestamps: dict[str, float] = field(default_factory=dict)
     _redis_listen_task: asyncio.Task | None = None
-    _running_sid_queries: dict[str, _ClusterQuery[set[str]]] = field(
+    _session_is_running_checks: dict[str, _SessionIsRunningCheck] = field(
         default_factory=dict
     )
     _active_conversations: dict[str, tuple[Conversation, int]] = field(
@@ -55,7 +52,7 @@ class SessionManager:
     )
     _conversations_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
     _cleanup_task: asyncio.Task | None = None
-    _connection_queries: dict[str, _ClusterQuery[dict[str, str]]] = field(
+    _has_remote_connections_flags: dict[str, asyncio.Event] = field(
         default_factory=dict
     )
 
@@ -63,7 +60,7 @@ async def __aenter__(self):
         redis_client = self._get_redis_client()
         if redis_client:
             self._redis_listen_task = asyncio.create_task(self._redis_subscribe())
-        self._cleanup_task = asyncio.create_task(self._cleanup_stale())
+        self._cleanup_task = asyncio.create_task(self._cleanup_detached_conversations())
         return self
 
     async def __aexit__(self, exc_type, exc_value, traceback):
@@ -85,7 +82,7 @@ async def _redis_subscribe(self):
         logger.debug('_redis_subscribe')
         redis_client = self._get_redis_client()
         pubsub = redis_client.pubsub()
-        await pubsub.subscribe('session_msg')
+        await pubsub.subscribe('oh_event')
         while should_continue():
             try:
                 message = await pubsub.get_message(
@@ -111,71 +108,59 @@ async def _process_message(self, message: dict):
             session = self._local_agent_loops_by_sid.get(sid)
             if session:
                 await session.dispatch(data['data'])
-        elif message_type == 'running_agent_loops_query':
+        elif message_type == 'is_session_running':
             # Another node in the cluster is asking if the current node is running the session given.
-            query_id = data['query_id']
-            sids = self._get_running_agent_loops_locally(
-                data.get('user_id'), data.get('filter_to_sids')
-            )
+            request_id = data['request_id']
+            sids = [
+                sid for sid in data['sids'] if sid in self._local_agent_loops_by_sid
+            ]
             if sids:
                 await self._get_redis_client().publish(
-                    'session_msg',
+                    'oh_event',
                     json.dumps(
                         {
-                            'query_id': query_id,
-                            'sids': list(sids),
-                            'message_type': 'running_agent_loops_response',
+                            'request_id': request_id,
+                            'sids': sids,
+                            'message_type': 'session_is_running',
                         }
                     ),
                 )
-        elif message_type == 'running_agent_loops_response':
-            query_id = data['query_id']
+        elif message_type == 'session_is_running':
+            request_id = data['request_id']
             for sid in data['sids']:
                 self._last_alive_timestamps[sid] = time.time()
-            running_query = self._running_sid_queries.get(query_id)
-            if running_query:
-                running_query.result.update(data['sids'])
-                if running_query.request_ids is not None and len(
-                    running_query.request_ids
-                ) == len(running_query.result):
-                    running_query.flag.set()
-        elif message_type == 'connections_query':
+            check = self._session_is_running_checks.get(request_id)
+            if check:
+                check.running_sids.update(data['sids'])
+                if len(check.request_sids) == len(check.running_sids):
+                    check.flag.set()
+        elif message_type == 'has_remote_connections_query':
             # Another node in the cluster is asking if the current node is connected to a session
-            query_id = data['query_id']
-            connections = self._get_connections_locally(
-                data.get('user_id'), data.get('filter_to_sids')
-            )
-            if connections:
+            sid = data['sid']
+            required = sid in self.local_connection_id_to_session_id.values()
+            if required:
                 await self._get_redis_client().publish(
-                    'session_msg',
+                    'oh_event',
                     json.dumps(
-                        {
-                            'query_id': query_id,
-                            'connections': connections,
-                            'message_type': 'connections_response',
-                        }
+                        {'sid': sid, 'message_type': 'has_remote_connections_response'}
                     ),
                 )
-        elif message_type == 'connections_response':
-            query_id = data['query_id']
-            connection_query = self._connection_queries.get(query_id)
-            if connection_query:
-                connection_query.result.update(**data['connections'])
-                if connection_query.request_ids is not None and len(
-                    connection_query.request_ids
-                ) == len(connection_query.result):
-                    connection_query.flag.set()
+        elif message_type == 'has_remote_connections_response':
+            sid = data['sid']
+            flag = self._has_remote_connections_flags.get(sid)
+            if flag:
+                flag.set()
         elif message_type == 'close_session':
             sid = data['sid']
             if sid in self._local_agent_loops_by_sid:
-                await self._close_session(sid)
+                await self._on_close_session(sid)
         elif message_type == 'session_closing':
             # Session closing event - We only get this in the event of graceful shutdown,
             # which can't be guaranteed - nodes can simply vanish unexpectedly!
             sid = data['sid']
             logger.debug(f'session_closing:{sid}')
             # Create a list of items to process to avoid modifying dict during iteration
-            items = list(self._local_connection_id_to_session_id.items())
+            items = list(self.local_connection_id_to_session_id.items())
             for connection_id, local_sid in items:
                 if sid == local_sid:
                     logger.warning(
@@ -223,7 +208,7 @@ async def join_conversation(
     ):
         logger.info(f'join_conversation:{sid}:{connection_id}')
         await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
-        self._local_connection_id_to_session_id[connection_id] = sid
+        self.local_connection_id_to_session_id[connection_id] = sid
         event_stream = await self._get_event_stream(sid)
         if not event_stream:
             return await self.maybe_start_agent_loop(sid, settings, user_id)
@@ -241,7 +226,7 @@ async def detach_from_conversation(self, conversation: Conversation):
                     self._active_conversations.pop(sid)
                     self._detached_conversations[sid] = (conversation, time.time())
 
-    async def _cleanup_stale(self):
+    async def _cleanup_detached_conversations(self):
         while should_continue():
             if self._get_redis_client():
                 # Debug info for HA envs
@@ -255,7 +240,7 @@ async def _cleanup_stale(self):
                     f'Running agent loops: {len(self._local_agent_loops_by_sid)}'
                 )
                 logger.info(
-                    f'Local connections: {len(self._local_connection_id_to_session_id)}'
+                    f'Local connections: {len(self.local_connection_id_to_session_id)}'
                 )
             try:
                 async with self._conversations_lock:
@@ -265,176 +250,97 @@ async def _cleanup_stale(self):
                         await conversation.disconnect()
                         self._detached_conversations.pop(sid, None)
 
-                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 = self._get_connections_locally(
-                    filter_to_sids=set(sid_to_close)
-                )
-                connected_sids = {sid for _, sid in connections.items()}
-                sid_to_close = [
-                    sid for sid in sid_to_close if sid not in connected_sids
-                ]
-
-                if sid_to_close:
-                    connections = await self._get_connections_remotely(
-                        filter_to_sids=set(sid_to_close)
-                    )
-                    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)
                 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 as e:
-                logger.warning(f'error_cleaning_stale: {str(e)}')
-                await asyncio.sleep(_CLEANUP_INTERVAL)
+                logger.warning(f'error_cleaning_detached_conversations: {str(e)}')
+                await asyncio.sleep(_CLEANUP_EXCEPTION_WAIT_TIME)
+
+    async def get_agent_loop_running(self, user_id, sids: set[str]) -> set[str]:
+        running_sids = set(sid for sid in sids if sid in self._local_agent_loops_by_sid)
+        check_cluster_sids = [sid for sid in sids if sid not in running_sids]
+        running_cluster_sids = await self.get_agent_loop_running_in_cluster(
+            check_cluster_sids
+        )
+        running_sids.union(running_cluster_sids)
+        return running_sids
 
     async def is_agent_loop_running(self, sid: str) -> bool:
-        sids = await self.get_running_agent_loops(filter_to_sids={sid})
-        return bool(sids)
-
-    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. 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."""
-        sids = self._get_running_agent_loops_locally(user_id, filter_to_sids)
-        remote_sids = await self._get_running_agent_loops_remotely(
-            user_id, filter_to_sids
-        )
-        return sids.union(remote_sids)
-
-    def _get_running_agent_loops_locally(
-        self, user_id: str | None = None, filter_to_sids: set[str] | None = None
-    ) -> set[str]:
-        items: Iterable[tuple[str, Session]] = self._local_agent_loops_by_sid.items()
-        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_running_agent_loops_remotely(
-        self,
-        user_id: str | None = None,
-        filter_to_sids: set[str] | None = None,
-    ) -> set[str]:
+        if await self.is_agent_loop_running_locally(sid):
+            return True
+        if await self.is_agent_loop_running_in_cluster(sid):
+            return True
+        return False
+
+    async def is_agent_loop_running_locally(self, sid: str) -> bool:
+        return sid in self._local_agent_loops_by_sid
+
+    async def is_agent_loop_running_in_cluster(self, sid: str) -> bool:
+        running_sids = await self.get_agent_loop_running_in_cluster([sid])
+        return bool(running_sids)
+
+    async def get_agent_loop_running_in_cluster(self, sids: list[str]) -> set[str]:
         """As the rest of the cluster if a session is running. Wait a for a short timeout for a reply"""
         redis_client = self._get_redis_client()
         if not redis_client:
             return set()
 
         flag = asyncio.Event()
-        query_id = str(uuid4())
-        query = _ClusterQuery[set[str]](
-            query_id=query_id, request_ids=filter_to_sids, result=set()
-        )
-        self._running_sid_queries[query_id] = query
+        request_id = str(uuid4())
+        check = _SessionIsRunningCheck(request_id=request_id, request_sids=sids)
+        self._session_is_running_checks[request_id] = check
         try:
-            logger.debug(
-                f'publish:_get_running_agent_loops_remotely_query:{user_id}:{filter_to_sids}'
+            logger.debug(f'publish:is_session_running:{sids}')
+            await redis_client.publish(
+                'oh_event',
+                json.dumps(
+                    {
+                        'request_id': request_id,
+                        'sids': sids,
+                        'message_type': 'is_session_running',
+                    }
+                ),
             )
-            data: dict = {
-                'query_id': query_id,
-                'message_type': 'running_agent_loops_query',
-            }
-            if user_id:
-                data['user_id'] = user_id
-            if filter_to_sids:
-                data['filter_to_sids'] = list(filter_to_sids)
-            await redis_client.publish('session_msg', json.dumps(data))
             async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
                 await flag.wait()
 
-            return query.result
+            return check.running_sids
         except TimeoutError:
             # Nobody replied in time
-            return query.result
+            return check.running_sids
         finally:
-            self._running_sid_queries.pop(query_id, None)
-
-    async def get_connections(
-        self, user_id: str | None = None, filter_to_sids: set[str] | None = None
-    ) -> dict[str, str]:
-        connection_ids = self._get_connections_locally(user_id, filter_to_sids)
-        remote_connection_ids = await self._get_connections_remotely(
-            user_id, filter_to_sids
-        )
-        connection_ids.update(**remote_connection_ids)
-        return connection_ids
-
-    def _get_connections_locally(
-        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 _get_connections_remotely(
-        self, user_id: str | None = None, filter_to_sids: set[str] | None = None
-    ) -> dict[str, str]:
-        redis_client = self._get_redis_client()
-        if not redis_client:
-            return {}
+            self._session_is_running_checks.pop(request_id, None)
 
+    async def _has_remote_connections(self, sid: str) -> bool:
+        """As the rest of the cluster if they still want this session running. Wait a for a short timeout for a reply"""
+        # Create a flag for the callback
         flag = asyncio.Event()
-        query_id = str(uuid4())
-        query = _ClusterQuery[dict[str, str]](
-            query_id=query_id, request_ids=filter_to_sids, result={}
-        )
-        self._connection_queries[query_id] = query
+        self._has_remote_connections_flags[sid] = flag
         try:
-            logger.debug(
-                f'publish:get_connections_remotely_query:{user_id}:{filter_to_sids}'
+            await self._get_redis_client().publish(
+                'oh_event',
+                json.dumps(
+                    {
+                        'sid': sid,
+                        'message_type': 'has_remote_connections_query',
+                    }
+                ),
             )
-            data: dict = {
-                'query_id': query_id,
-                'message_type': 'connections_query',
-            }
-            if user_id:
-                data['user_id'] = user_id
-            if filter_to_sids:
-                data['filter_to_sids'] = list(filter_to_sids)
-            await redis_client.publish('session_msg', json.dumps(data))
             async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
                 await flag.wait()
 
-            return query.result
+            result = flag.is_set()
+            return result
         except TimeoutError:
             # Nobody replied in time
-            return query.result
+            return False
         finally:
-            self._connection_queries.pop(query_id, None)
+            self._has_remote_connections_flags.pop(sid, None)
 
     async def maybe_start_agent_loop(
         self, sid: str, settings: Settings, user_id: str | None
@@ -443,18 +349,8 @@ async def maybe_start_agent_loop(
         session: Session | None = None
         if not await self.is_agent_loop_running(sid):
             logger.info(f'start_agent_loop:{sid}')
-
-            response_ids = await self.get_running_agent_loops(user_id)
-            if len(response_ids) >= MAX_RUNNING_CONVERSATIONS:
-                logger.info('too_many_sessions_for:{user_id}')
-                await self.close_session(next(iter(response_ids)))
-
             session = Session(
-                sid=sid,
-                file_store=self.file_store,
-                config=self.config,
-                sio=self.sio,
-                user_id=user_id,
+                sid=sid, file_store=self.file_store, config=self.config, sio=self.sio
             )
             self._local_agent_loops_by_sid[sid] = session
             asyncio.create_task(session.initialize_agent(settings))
@@ -463,6 +359,7 @@ async def maybe_start_agent_loop(
         if not event_stream:
             logger.error(f'No event stream after starting agent loop: {sid}')
             raise RuntimeError(f'no_event_stream:{sid}')
+        asyncio.create_task(self._cleanup_session_later(sid))
         return event_stream
 
     async def _get_event_stream(self, sid: str) -> EventStream | None:
@@ -472,7 +369,7 @@ async def _get_event_stream(self, sid: str) -> EventStream | None:
             logger.info(f'found_local_agent_loop:{sid}')
             return session.agent_session.event_stream
 
-        if await self._get_running_agent_loops_remotely(filter_to_sids={sid}):
+        if await self.is_agent_loop_running_in_cluster(sid):
             logger.info(f'found_remote_agent_loop:{sid}')
             return EventStream(sid, self.file_store)
 
@@ -480,7 +377,7 @@ async def _get_event_stream(self, sid: str) -> EventStream | None:
 
     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)
+        sid = self.local_connection_id_to_session_id.get(connection_id)
         if not sid:
             raise RuntimeError(f'no_connected_session:{connection_id}')
 
@@ -496,11 +393,11 @@ async def send_to_event_stream(self, connection_id: str, data: dict):
             next_alive_check = last_alive_at + _CHECK_ALIVE_INTERVAL
             if (
                 next_alive_check > time.time()
-                or await self._get_running_agent_loops_remotely(filter_to_sids={sid})
+                or await self.is_agent_loop_running_in_cluster(sid)
             ):
                 # Send the event to the other pod
                 await redis_client.publish(
-                    'session_msg',
+                    'oh_event',
                     json.dumps(
                         {
                             'sid': sid,
@@ -514,37 +411,75 @@ async def send_to_event_stream(self, connection_id: str, data: dict):
         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)
+        sid = self.local_connection_id_to_session_id.pop(connection_id, None)
         logger.info(f'disconnect_from_session:{connection_id}:{sid}')
         if not sid:
             # This can occur if the init action was never run.
             logger.warning(f'disconnect_from_uninitialized_session:{connection_id}')
             return
 
+        if should_continue():
+            asyncio.create_task(self._cleanup_session_later(sid))
+        else:
+            await self._on_close_session(sid)
+
+    async def _cleanup_session_later(self, sid: str):
+        # Once there have been no connections to a session for a reasonable period, we close it
+        try:
+            await asyncio.sleep(self.config.sandbox.close_delay)
+        finally:
+            # If the sleep was cancelled, we still want to close these
+            await self._cleanup_session(sid)
+
+    async def _cleanup_session(self, sid: str) -> bool:
+        # Get local connections
+        logger.info(f'_cleanup_session:{sid}')
+        has_local_connections = next(
+            (True for v in self.local_connection_id_to_session_id.values() if v == sid),
+            False,
+        )
+        if has_local_connections:
+            return False
+
+        # If no local connections, get connections through redis
+        redis_client = self._get_redis_client()
+        if redis_client and await self._has_remote_connections(sid):
+            return False
+
+        # We alert the cluster in case they are interested
+        if redis_client:
+            await redis_client.publish(
+                'oh_event',
+                json.dumps({'sid': sid, 'message_type': 'session_closing'}),
+            )
+
+        await self._on_close_session(sid)
+        return True
+
     async def close_session(self, sid: str):
         session = self._local_agent_loops_by_sid.get(sid)
         if session:
-            await self._close_session(sid)
+            await self._on_close_session(sid)
 
         redis_client = self._get_redis_client()
         if redis_client:
             await redis_client.publish(
-                'session_msg',
+                'oh_event',
                 json.dumps({'sid': sid, 'message_type': 'close_session'}),
             )
 
-    async def _close_session(self, sid: str):
+    async def _on_close_session(self, sid: str):
         logger.info(f'_close_session:{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()
+            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}')
         for connnnection_id in connection_ids_to_remove:
-            self._local_connection_id_to_session_id.pop(connnnection_id, None)
+            self.local_connection_id_to_session_id.pop(connnnection_id, None)
 
         session = self._local_agent_loops_by_sid.pop(sid, None)
         if not session:
@@ -553,17 +488,12 @@ async def _close_session(self, sid: str):
 
         logger.info(f'closing_session:{session.sid}')
         # We alert the cluster in case they are interested
-        try:
-            redis_client = self._get_redis_client()
-            if redis_client:
-                await redis_client.publish(
-                    'session_msg',
-                    json.dumps({'sid': session.sid, 'message_type': 'session_closing'}),
-                )
-        except Exception:
-            logger.info(
-                'error_publishing_close_session_event', exc_info=True, stack_info=True
+        redis_client = self._get_redis_client()
+        if redis_client:
+            await redis_client.publish(
+                'oh_event',
+                json.dumps({'sid': session.sid, 'message_type': 'session_closing'}),
             )
 
-        await session.close()
+        await call_sync_from_async(session.close)
         logger.info(f'closed_session:{session.sid}')
diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py
index e77a77101b20..8318ab773129 100644
--- a/openhands/server/session/session.py
+++ b/openhands/server/session/session.py
@@ -62,17 +62,9 @@ def __init__(
         self.loop = asyncio.get_event_loop()
         self.user_id = user_id
 
-    async def close(self):
-        if self.sio:
-            await self.sio.emit(
-                'oh_event',
-                event_to_dict(
-                    AgentStateChangedObservation('', AgentState.STOPPED.value)
-                ),
-                to=ROOM_KEY.format(sid=self.sid),
-            )
+    def close(self):
         self.is_alive = False
-        await self.agent_session.close()
+        self.agent_session.close()
 
     async def initialize_agent(
         self,
diff --git a/openhands/utils/http_session.py b/openhands/utils/http_session.py
deleted file mode 100644
index 4edc4e6546c3..000000000000
--- a/openhands/utils/http_session.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from dataclasses import dataclass, field
-
-import requests
-
-
-@dataclass
-class HttpSession:
-    """
-    request.Session is reusable after it has been closed. This behavior makes it
-    likely to leak file descriptors (Especially when combined with tenacity).
-    We wrap the session to make it unusable after being closed
-    """
-
-    session: requests.Session | None = field(default_factory=requests.Session)
-
-    def __getattr__(self, name):
-        if self.session is None:
-            raise ValueError('session_was_closed')
-        return object.__getattribute__(self.session, name)
-
-    def close(self):
-        if self.session is not None:
-            self.session.close()
-        self.session = None
diff --git a/tests/unit/test_manager.py b/tests/unit/test_manager.py
index cd2ddf6ba0a6..f0ac68ff8361 100644
--- a/tests/unit/test_manager.py
+++ b/tests/unit/test_manager.py
@@ -44,28 +44,28 @@ async def test_session_not_running_in_cluster():
         async with SessionManager(
             sio, AppConfig(), InMemoryFileStore()
         ) as session_manager:
-            result = await session_manager._get_running_agent_loops_remotely(
-                filter_to_sids={'non-existant-session'}
+            result = await session_manager.is_agent_loop_running_in_cluster(
+                'non-existant-session'
             )
-            assert result == set()
+            assert result is False
             assert sio.manager.redis.publish.await_count == 1
             sio.manager.redis.publish.assert_called_once_with(
-                'session_msg',
-                '{"query_id": "'
+                'oh_event',
+                '{"request_id": "'
                 + str(id)
-                + '", "message_type": "running_agent_loops_query", "filter_to_sids": ["non-existant-session"]}',
+                + '", "sids": ["non-existant-session"], "message_type": "is_session_running"}',
             )
 
 
 @pytest.mark.asyncio
-async def test_get_running_agent_loops_remotely():
+async def test_session_is_running_in_cluster():
     id = uuid4()
     sio = get_mock_sio(
         GetMessageMock(
             {
-                'query_id': str(id),
+                'request_id': str(id),
                 'sids': ['existing-session'],
-                'message_type': 'running_agent_loops_response',
+                'message_type': 'session_is_running',
             }
         )
     )
@@ -76,16 +76,16 @@ async def test_get_running_agent_loops_remotely():
         async with SessionManager(
             sio, AppConfig(), InMemoryFileStore()
         ) as session_manager:
-            result = await session_manager._get_running_agent_loops_remotely(
-                1, {'existing-session'}
+            result = await session_manager.is_agent_loop_running_in_cluster(
+                'existing-session'
             )
-            assert result == {'existing-session'}
+            assert result is True
             assert sio.manager.redis.publish.await_count == 1
             sio.manager.redis.publish.assert_called_once_with(
-                'session_msg',
-                '{"query_id": "'
+                'oh_event',
+                '{"request_id": "'
                 + str(id)
-                + '", "message_type": "running_agent_loops_query", "user_id": 1, "filter_to_sids": ["existing-session"]}',
+                + '", "sids": ["existing-session"], "message_type": "is_session_running"}',
             )
 
 
@@ -96,8 +96,8 @@ async def test_init_new_local_session():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    get_running_agent_loops_mock = AsyncMock()
-    get_running_agent_loops_mock.return_value = set()
+    is_agent_loop_running_in_cluster_mock = AsyncMock()
+    is_agent_loop_running_in_cluster_mock.return_value = False
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.1),
@@ -106,8 +106,8 @@ async def test_init_new_local_session():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager.get_running_agent_loops',
-            get_running_agent_loops_mock,
+            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
+            is_agent_loop_running_in_cluster_mock,
         ),
     ):
         async with SessionManager(
@@ -130,8 +130,8 @@ async def test_join_local_session():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    get_running_agent_loops_mock = AsyncMock()
-    get_running_agent_loops_mock.return_value = set()
+    is_agent_loop_running_in_cluster_mock = AsyncMock()
+    is_agent_loop_running_in_cluster_mock.return_value = False
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
@@ -140,8 +140,8 @@ async def test_join_local_session():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager.get_running_agent_loops',
-            get_running_agent_loops_mock,
+            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
+            is_agent_loop_running_in_cluster_mock,
         ),
     ):
         async with SessionManager(
@@ -167,8 +167,8 @@ async def test_join_cluster_session():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    get_running_agent_loops_mock = AsyncMock()
-    get_running_agent_loops_mock.return_value = {'new-session-id'}
+    is_agent_loop_running_in_cluster_mock = AsyncMock()
+    is_agent_loop_running_in_cluster_mock.return_value = True
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
@@ -177,8 +177,8 @@ async def test_join_cluster_session():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager._get_running_agent_loops_remotely',
-            get_running_agent_loops_mock,
+            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
+            is_agent_loop_running_in_cluster_mock,
         ),
     ):
         async with SessionManager(
@@ -198,8 +198,8 @@ async def test_add_to_local_event_stream():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    get_running_agent_loops_mock = AsyncMock()
-    get_running_agent_loops_mock.return_value = set()
+    is_agent_loop_running_in_cluster_mock = AsyncMock()
+    is_agent_loop_running_in_cluster_mock.return_value = False
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
@@ -208,8 +208,8 @@ async def test_add_to_local_event_stream():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager.get_running_agent_loops',
-            get_running_agent_loops_mock,
+            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
+            is_agent_loop_running_in_cluster_mock,
         ),
     ):
         async with SessionManager(
@@ -234,8 +234,8 @@ async def test_add_to_cluster_event_stream():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    get_running_agent_loops_mock = AsyncMock()
-    get_running_agent_loops_mock.return_value = {'new-session-id'}
+    is_agent_loop_running_in_cluster_mock = AsyncMock()
+    is_agent_loop_running_in_cluster_mock.return_value = True
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
@@ -244,8 +244,8 @@ async def test_add_to_cluster_event_stream():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager._get_running_agent_loops_remotely',
-            get_running_agent_loops_mock,
+            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
+            is_agent_loop_running_in_cluster_mock,
         ),
     ):
         async with SessionManager(
@@ -259,7 +259,7 @@ async def test_add_to_cluster_event_stream():
             )
     assert sio.manager.redis.publish.await_count == 1
     sio.manager.redis.publish.assert_called_once_with(
-        'session_msg',
+        'oh_event',
         '{"sid": "new-session-id", "message_type": "event", "data": {"event_type": "some_event"}}',
     )
 
@@ -277,7 +277,7 @@ async def test_cleanup_session_connections():
         async with SessionManager(
             sio, AppConfig(), InMemoryFileStore()
         ) as session_manager:
-            session_manager._local_connection_id_to_session_id.update(
+            session_manager.local_connection_id_to_session_id.update(
                 {
                     'conn1': 'session1',
                     'conn2': 'session1',
@@ -286,9 +286,9 @@ async def test_cleanup_session_connections():
                 }
             )
 
-            await session_manager._close_session('session1')
+            await session_manager._on_close_session('session1')
 
-            remaining_connections = session_manager._local_connection_id_to_session_id
+            remaining_connections = session_manager.local_connection_id_to_session_id
             assert 'conn1' not in remaining_connections
             assert 'conn2' not in remaining_connections
             assert 'conn3' in remaining_connections

From 578291e9614425f972731fd24520312273201a6b Mon Sep 17 00:00:00 2001
From: Alejandro Cuadron Lafuente <alex.cl.2000@gmail.com>
Date: Thu, 16 Jan 2025 15:53:11 +0100
Subject: [PATCH 03/39] Enabled native function calling for O1 + added support
 for reasoning_effort config in the config. (#6256)

Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
---
 config.template.toml                |  3 +++
 openhands/core/config/llm_config.py |  2 ++
 openhands/llm/async_llm.py          | 10 +++++++++-
 openhands/llm/llm.py                | 18 +++++++++++++++---
 openhands/llm/streaming_llm.py      |  5 +++++
 5 files changed, 34 insertions(+), 4 deletions(-)

diff --git a/config.template.toml b/config.template.toml
index 8f26eaf92b88..aefb52376803 100644
--- a/config.template.toml
+++ b/config.template.toml
@@ -23,6 +23,9 @@ workspace_base = "./workspace"
 # Cache directory path
 #cache_dir = "/tmp/cache"
 
+# Reasoning effort for o1 models (low, medium, high, or not set)
+#reasoning_effort = "medium"
+
 # Debugging enabled
 #debug = false
 
diff --git a/openhands/core/config/llm_config.py b/openhands/core/config/llm_config.py
index 16c08a7693f0..bae58373811d 100644
--- a/openhands/core/config/llm_config.py
+++ b/openhands/core/config/llm_config.py
@@ -40,6 +40,7 @@ class LLMConfig:
         drop_params: Drop any unmapped (unsupported) params without causing an exception.
         modify_params: Modify params allows litellm to do transformations like adding a default message, when a message is empty.
         disable_vision: If model is vision capable, this option allows to disable image processing (useful for cost reduction).
+        reasoning_effort: The effort to put into reasoning. This is a string that can be one of 'low', 'medium', 'high', or 'none'. Exclusive for o1 models.
         caching_prompt: Use the prompt caching feature if provided by the LLM and supported by the provider.
         log_completions: Whether to log LLM completions to the state.
         log_completions_folder: The folder to log LLM completions to. Required if log_completions is True.
@@ -79,6 +80,7 @@ class LLMConfig:
     # Note: this setting is actually global, unlike drop_params
     modify_params: bool = True
     disable_vision: bool | None = None
+    reasoning_effort: str | None = None
     caching_prompt: bool = True
     log_completions: bool = False
     log_completions_folder: str = os.path.join(LOG_DIR, 'completions')
diff --git a/openhands/llm/async_llm.py b/openhands/llm/async_llm.py
index ed84273c737b..f553ae173fd6 100644
--- a/openhands/llm/async_llm.py
+++ b/openhands/llm/async_llm.py
@@ -6,7 +6,11 @@
 
 from openhands.core.exceptions import UserCancelledError
 from openhands.core.logger import openhands_logger as logger
-from openhands.llm.llm import LLM, LLM_RETRY_EXCEPTIONS
+from openhands.llm.llm import (
+    LLM,
+    LLM_RETRY_EXCEPTIONS,
+    REASONING_EFFORT_SUPPORTED_MODELS,
+)
 from openhands.utils.shutdown_listener import should_continue
 
 
@@ -55,6 +59,10 @@ async def async_completion_wrapper(*args, **kwargs):
             elif 'messages' in kwargs:
                 messages = kwargs['messages']
 
+            # Set reasoning effort for models that support it
+            if self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS:
+                kwargs['reasoning_effort'] = self.config.reasoning_effort
+
             # ensure we work with a list of messages
             messages = messages if isinstance(messages, list) else [messages]
 
diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py
index 743d6535ba3b..82fc6822543f 100644
--- a/openhands/llm/llm.py
+++ b/openhands/llm/llm.py
@@ -70,7 +70,15 @@
     'claude-3.5-haiku',
     'claude-3-5-haiku-20241022',
     'gpt-4o-mini',
-    'gpt-4o',
+    'o1-2024-12-17',
+]
+
+REASONING_EFFORT_SUPPORTED_MODELS = [
+    'o1-2024-12-17',
+]
+
+MODELS_WITHOUT_STOP_WORDS = [
+    'o1-mini',
 ]
 
 
@@ -186,7 +194,8 @@ def wrapper(*args, **kwargs):
                     messages, kwargs['tools']
                 )
                 kwargs['messages'] = messages
-                kwargs['stop'] = STOP_WORDS
+                if self.config.model not in MODELS_WITHOUT_STOP_WORDS:
+                    kwargs['stop'] = STOP_WORDS
                 mock_fncall_tools = kwargs.pop('tools')
 
             # if we have no messages, something went very wrong
@@ -205,6 +214,10 @@ def wrapper(*args, **kwargs):
                         'anthropic-beta': 'prompt-caching-2024-07-31',
                     }
 
+            # Set reasoning effort for models that support it
+            if self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS:
+                kwargs['reasoning_effort'] = self.config.reasoning_effort
+
             # set litellm modify_params to the configured value
             # True by default to allow litellm to do transformations like adding a default message, when a message is empty
             # NOTE: this setting is global; unlike drop_params, it cannot be overridden in the litellm completion partial
@@ -213,7 +226,6 @@ def wrapper(*args, **kwargs):
             try:
                 # Record start time for latency measurement
                 start_time = time.time()
-
                 # we don't support streaming here, thus we get a ModelResponse
                 resp: ModelResponse = self._completion_unwrapped(*args, **kwargs)
 
diff --git a/openhands/llm/streaming_llm.py b/openhands/llm/streaming_llm.py
index 77d999fadcd3..10925b9564cf 100644
--- a/openhands/llm/streaming_llm.py
+++ b/openhands/llm/streaming_llm.py
@@ -5,6 +5,7 @@
 from openhands.core.exceptions import UserCancelledError
 from openhands.core.logger import openhands_logger as logger
 from openhands.llm.async_llm import LLM_RETRY_EXCEPTIONS, AsyncLLM
+from openhands.llm.llm import REASONING_EFFORT_SUPPORTED_MODELS
 
 
 class StreamingLLM(AsyncLLM):
@@ -61,6 +62,10 @@ async def async_streaming_completion_wrapper(*args, **kwargs):
                     'The messages list is empty. At least one message is required.'
                 )
 
+            # Set reasoning effort for models that support it
+            if self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS:
+                kwargs['reasoning_effort'] = self.config.reasoning_effort
+
             self.log_prompt(messages)
 
             try:

From 8c35150c3eb5596127c89edcc29f214265ab5f14 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 16 Jan 2025 16:18:37 +0100
Subject: [PATCH 04/39] chore(deps-dev): bump llama-index from 0.12.10 to
 0.12.11 in the llama group (#6308)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 poetry.lock | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/poetry.lock b/poetry.lock
index 3cd9f4706f2e..06c119ee3497 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3751,19 +3751,19 @@ pydantic = ">=1.10"
 
 [[package]]
 name = "llama-index"
-version = "0.12.10"
+version = "0.12.11"
 description = "Interface between LLMs and your data"
 optional = false
 python-versions = "<4.0,>=3.9"
 files = [
-    {file = "llama_index-0.12.10-py3-none-any.whl", hash = "sha256:c397e1355d48a043a4636857519185f9a47eb25e6482134c28e75f64cd4fe11e"},
-    {file = "llama_index-0.12.10.tar.gz", hash = "sha256:942bd89f6363a553ff30f053df3c12703ac81c726d1afb7fc14555b0ede5e8a2"},
+    {file = "llama_index-0.12.11-py3-none-any.whl", hash = "sha256:007361c35e1981a1656cef287b7bcdf22aa88e7d41b8e3a8ee261bb5a10519a9"},
+    {file = "llama_index-0.12.11.tar.gz", hash = "sha256:b1116946a2414aec104a6c417b847da5b4f077a0966c50ebd2fc445cd713adce"},
 ]
 
 [package.dependencies]
 llama-index-agent-openai = ">=0.4.0,<0.5.0"
 llama-index-cli = ">=0.4.0,<0.5.0"
-llama-index-core = ">=0.12.10,<0.13.0"
+llama-index-core = ">=0.12.11,<0.13.0"
 llama-index-embeddings-openai = ">=0.3.0,<0.4.0"
 llama-index-indices-managed-llama-cloud = ">=0.4.0"
 llama-index-llms-openai = ">=0.3.0,<0.4.0"
@@ -3808,13 +3808,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0"
 
 [[package]]
 name = "llama-index-core"
-version = "0.12.10.post1"
+version = "0.12.11"
 description = "Interface between LLMs and your data"
 optional = false
 python-versions = "<4.0,>=3.9"
 files = [
-    {file = "llama_index_core-0.12.10.post1-py3-none-any.whl", hash = "sha256:897e8cd4efeff6842580b043bdf4008ac60f693df1de2bfd975307a4845707c2"},
-    {file = "llama_index_core-0.12.10.post1.tar.gz", hash = "sha256:af27bea4d1494ba84983a649976e60e3de677a73946aa45ed12ce27e3a623ddf"},
+    {file = "llama_index_core-0.12.11-py3-none-any.whl", hash = "sha256:3b1e019c899e9e011dfa01c96b7e3f666e0c161035fbca6cb787b4c61e0c94db"},
+    {file = "llama_index_core-0.12.11.tar.gz", hash = "sha256:9a41ca91167ea5eec9ebaac7f5e958b7feddbd8af3bfbf7c393a5edfb994d566"},
 ]
 
 [package.dependencies]

From 8579710c821396efb11c749a3413d88ba0f2fd48 Mon Sep 17 00:00:00 2001
From: Alejandro Cuadron Lafuente <alex.cl.2000@gmail.com>
Date: Thu, 16 Jan 2025 16:27:57 +0100
Subject: [PATCH 05/39] [Fix] Restored FC default for GPT-4o (#6311)

---
 openhands/llm/llm.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py
index 82fc6822543f..16dc532c66be 100644
--- a/openhands/llm/llm.py
+++ b/openhands/llm/llm.py
@@ -70,6 +70,7 @@
     'claude-3.5-haiku',
     'claude-3-5-haiku-20241022',
     'gpt-4o-mini',
+    'gpt-4o',
     'o1-2024-12-17',
 ]
 

From e211647ebadd4b2545320d7651eca408f2563b11 Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Thu, 16 Jan 2025 10:33:22 -0500
Subject: [PATCH 06/39] fix: llm-proxy `response_cost` being 0 (#6293)

---
 openhands/llm/llm.py | 19 +++++++++----------
 1 file changed, 9 insertions(+), 10 deletions(-)

diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py
index 16dc532c66be..88cda96c5f00 100644
--- a/openhands/llm/llm.py
+++ b/openhands/llm/llm.py
@@ -610,17 +610,16 @@ def _completion_cost(self, response) -> float:
             logger.debug(f'Using custom cost per token: {cost_per_token}')
             extra_kwargs['custom_cost_per_token'] = cost_per_token
 
-        try:
-            # try directly get response_cost from response
-            _hidden_params = getattr(response, '_hidden_params', {})
-            cost = _hidden_params.get('response_cost', None)
-            if cost is None:
-                cost = float(
-                    _hidden_params.get('additional_headers', {}).get(
-                        'llm_provider-x-litellm-response-cost', 0.0
-                    )
-                )
+        # try directly get response_cost from response
+        _hidden_params = getattr(response, '_hidden_params', {})
+        cost = _hidden_params.get('additional_headers', {}).get(
+            'llm_provider-x-litellm-response-cost', None
+        )
+        if cost is not None:
+            cost = float(cost)
+            logger.debug(f'Got response_cost from response: {cost}')
 
+        try:
             if cost is None:
                 try:
                     cost = litellm_completion_cost(

From df050e47860fe5e0afb49b0448f4af3621781961 Mon Sep 17 00:00:00 2001
From: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
Date: Thu, 16 Jan 2025 11:40:03 -0500
Subject: [PATCH 07/39] Separate data extraction and convo creation logic
 (#6298)

---
 openhands/server/listen_socket.py             |  3 +-
 .../server/routes/manage_conversations.py     | 84 ++++++++++++-------
 openhands/server/types.py                     | 12 +++
 3 files changed, 70 insertions(+), 29 deletions(-)

diff --git a/openhands/server/listen_socket.py b/openhands/server/listen_socket.py
index 81816a1325e1..baa79a4c6e35 100644
--- a/openhands/server/listen_socket.py
+++ b/openhands/server/listen_socket.py
@@ -44,7 +44,8 @@ async def connect(connection_id: str, environ, auth):
 
         conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
         metadata = await conversation_store.get_metadata(conversation_id)
-        if metadata.github_user_id != user_id:
+
+        if metadata.github_user_id != str(user_id):
             logger.error(
                 f'User {user_id} is not allowed to join conversation {conversation_id}'
             )
diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py
index 5cfc0ba82d52..0015cf0e90aa 100644
--- a/openhands/server/routes/manage_conversations.py
+++ b/openhands/server/routes/manage_conversations.py
@@ -12,6 +12,7 @@
 from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
 from openhands.server.session.conversation_init_data import ConversationInitData
 from openhands.server.shared import config, session_manager
+from openhands.server.types import LLMAuthenticationError, MissingSettingsError
 from openhands.storage.data_models.conversation_info import ConversationInfo
 from openhands.storage.data_models.conversation_info_result_set import (
     ConversationInfoResultSet,
@@ -33,16 +34,12 @@ class InitSessionRequest(BaseModel):
     selected_repository: str | None = None
 
 
-@app.post('/conversations')
-async def new_conversation(request: Request, data: InitSessionRequest):
-    """Initialize a new session or join an existing one.
-    After successful initialization, the client should connect to the WebSocket
-    using the returned conversation ID
-    """
-    logger.info('Initializing new conversation')
-
+async def _create_new_conversation(
+    user_id: str | None,
+    token: str | None,
+    selected_repository: str | None,
+):
     logger.info('Loading settings')
-    user_id = get_user_id(request)
     settings_store = await SettingsStoreImpl.get_instance(config, user_id)
     settings = await settings_store.load()
     logger.info('Settings loaded')
@@ -54,25 +51,16 @@ async def new_conversation(request: Request, data: InitSessionRequest):
         # but that would run a tiny inference.
         if not settings.llm_api_key or settings.llm_api_key.isspace():
             logger.warn(f'Missing api key for model {settings.llm_model}')
-            return JSONResponse(
-                content={
-                    'status': 'error',
-                    'message': 'Error authenticating with the LLM provider. Please check your API key',
-                    'msg_id': 'STATUS$ERROR_LLM_AUTHENTICATION',
-                }
+            raise LLMAuthenticationError(
+                'Error authenticating with the LLM provider. Please check your API key'
             )
+
     else:
         logger.warn('Settings not present, not starting conversation')
-        return JSONResponse(
-            content={
-                'status': 'error',
-                'message': 'Settings not found',
-                'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND',
-            }
-        )
-    github_token = getattr(request.state, 'github_token', '')
-    session_init_args['github_token'] = github_token or data.github_token or ''
-    session_init_args['selected_repository'] = data.selected_repository
+        raise MissingSettingsError('Settings not found')
+
+    session_init_args['github_token'] = token or ''
+    session_init_args['selected_repository'] = selected_repository
     conversation_init_data = ConversationInitData(**session_init_args)
     logger.info('Loading conversation store')
     conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
@@ -85,7 +73,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
     logger.info(f'New conversation ID: {conversation_id}')
 
     repository_title = (
-        data.selected_repository.split('/')[-1] if data.selected_repository else None
+        selected_repository.split('/')[-1] if selected_repository else None
     )
     conversation_title = f'{repository_title or "Conversation"} {conversation_id[:5]}'
 
@@ -95,7 +83,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
             conversation_id=conversation_id,
             title=conversation_title,
             github_user_id=user_id,
-            selected_repository=data.selected_repository,
+            selected_repository=selected_repository,
         )
     )
 
@@ -112,7 +100,47 @@ async def new_conversation(request: Request, data: InitSessionRequest):
     except ValueError:
         pass  # Already subscribed - take no action
     logger.info(f'Finished initializing conversation {conversation_id}')
-    return JSONResponse(content={'status': 'ok', 'conversation_id': conversation_id})
+
+    return conversation_id
+
+
+@app.post('/conversations')
+async def new_conversation(request: Request, data: InitSessionRequest):
+    """Initialize a new session or join an existing one.
+    After successful initialization, the client should connect to the WebSocket
+    using the returned conversation ID
+    """
+    logger.info('Initializing new conversation')
+    user_id = get_user_id(request)
+    github_token = getattr(request.state, 'github_token', '') or data.github_token
+    selected_repository = data.selected_repository
+
+    try:
+        conversation_id = await _create_new_conversation(
+            user_id, github_token, selected_repository
+        )
+
+        return JSONResponse(
+            content={'status': 'ok', 'conversation_id': conversation_id}
+        )
+    except MissingSettingsError as e:
+        return JSONResponse(
+            content={
+                'status': 'error',
+                'message': str(e),
+                'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND',
+            },
+            status_code=400,
+        )
+
+    except LLMAuthenticationError as e:
+        return JSONResponse(
+            content={
+                'status': 'error',
+                'message': str(e),
+                'msg_id': 'STATUS$ERROR_LLM_AUTHENTICATION',
+            },
+        )
 
 
 @app.get('/conversations')
diff --git a/openhands/server/types.py b/openhands/server/types.py
index 8ecb898a76cf..cbf9389d2b44 100644
--- a/openhands/server/types.py
+++ b/openhands/server/types.py
@@ -35,3 +35,15 @@ async def verify_github_repo_list(self, installation_id: int | None) -> None:
     async def get_config(self) -> dict[str, str]:
         """Configure attributes for frontend"""
         raise NotImplementedError
+
+
+class MissingSettingsError(ValueError):
+    """Raised when settings are missing or not found."""
+
+    pass
+
+
+class LLMAuthenticationError(ValueError):
+    """Raised when there is an issue with LLM authentication."""
+
+    pass

From da1a6035ac56de33083a22851662967dc4868b5b Mon Sep 17 00:00:00 2001
From: "Ryan H. Tran" <descience.thh10@gmail.com>
Date: Thu, 16 Jan 2025 23:42:22 +0700
Subject: [PATCH 08/39] Enable runtime build in `openhands-resolver.yml`
 (#6312)

---
 .github/workflows/openhands-resolver.yml | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml
index 028316ee05d5..a9d90c38b139 100644
--- a/.github/workflows/openhands-resolver.yml
+++ b/.github/workflows/openhands-resolver.yml
@@ -184,6 +184,7 @@ jobs:
             });
 
       - name: Install OpenHands
+        id: install_openhands
         uses: actions/github-script@v7
         env:
           COMMENT_BODY: ${{ github.event.comment.body || '' }}
@@ -196,7 +197,6 @@ jobs:
             const reviewBody = process.env.REVIEW_BODY.trim();
             const labelName = process.env.LABEL_NAME.trim();
             const eventName = process.env.EVENT_NAME.trim();
-
             // Check conditions
             const isExperimentalLabel = labelName === "fix-me-experimental";
             const isIssueCommentExperimental =
@@ -205,6 +205,9 @@ jobs:
             const isReviewCommentExperimental =
               eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp");
 
+            // Set output variable
+            core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental);
+
             // Perform package installation
             if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
               console.log("Installing experimental OpenHands...");
@@ -230,7 +233,8 @@ jobs:
             --issue-number ${{ env.ISSUE_NUMBER }} \
             --issue-type ${{ env.ISSUE_TYPE }} \
             --max-iterations ${{ env.MAX_ITERATIONS }} \
-            --comment-id ${{ env.COMMENT_ID }}
+            --comment-id ${{ env.COMMENT_ID }} \
+            --is-experimental ${{ steps.install_openhands.outputs.isExperimental }}
 
       - name: Check resolution result
         id: check_result

From 0bed17758f05aee421bb0148ab0d6df59bd574fe Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Thu, 16 Jan 2025 12:27:00 -0500
Subject: [PATCH 09/39] fix: incorrect soft-timeout implementation & fix
 hard-timeout follow-up command (#6280)

---
 .../benchmarks/commit0_bench/run_infer.py     |  24 +--
 evaluation/benchmarks/swe_bench/eval_infer.py |  10 +-
 evaluation/benchmarks/swe_bench/run_infer.py  |  30 ++--
 .../benchmarks/the_agent_company/browsing.py  |   2 +-
 .../benchmarks/the_agent_company/run_infer.py |   4 +-
 openhands/events/event.py                     |  10 +-
 openhands/events/serialization/action.py      |   3 +-
 openhands/resolver/resolve_issue.py           |   2 +-
 openhands/runtime/action_execution_server.py  |   5 +-
 openhands/runtime/base.py                     |   3 +-
 .../action_execution_client.py                |   3 +-
 .../runtime/impl/docker/docker_runtime.py     |   2 +
 openhands/runtime/utils/bash.py               |  49 +++++-
 tests/runtime/test_bash.py                    | 160 +++++++++++++++---
 tests/runtime/test_stress_docker_runtime.py   |   2 +-
 tests/runtime/test_stress_remote_runtime.py   |   4 +-
 tests/unit/test_bash_session.py               |  16 +-
 tests/unit/test_runtime_reboot.py             |   4 +-
 18 files changed, 243 insertions(+), 90 deletions(-)

diff --git a/evaluation/benchmarks/commit0_bench/run_infer.py b/evaluation/benchmarks/commit0_bench/run_infer.py
index d8f1f64b1a6b..e690952ab9a9 100644
--- a/evaluation/benchmarks/commit0_bench/run_infer.py
+++ b/evaluation/benchmarks/commit0_bench/run_infer.py
@@ -171,7 +171,7 @@ def initialize_runtime(
     action = CmdRunAction(
         command=f'git clone -b commit0_combined https://github.com/{instance["repo"]}.git'
     )
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -181,7 +181,7 @@ def initialize_runtime(
     )
 
     action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -191,7 +191,7 @@ def initialize_runtime(
     )
 
     action = CmdRunAction(command='git checkout -b openhands')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -201,7 +201,7 @@ def initialize_runtime(
 
     # Install commit0
     action = CmdRunAction(command='/root/.cargo/bin/uv pip install commit0')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -231,7 +231,7 @@ def complete_runtime(
     workspace_dir_name = _get_commit0_workspace_dir_name(instance)
 
     action = CmdRunAction(command='git add .')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -241,7 +241,7 @@ def complete_runtime(
     )
 
     action = CmdRunAction(command='git commit -m "openhands edits"')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -258,7 +258,7 @@ def complete_runtime(
         action = CmdRunAction(
             command=f"git diff {instance['base_commit']} HEAD -- . ':(exclude)spec.pdf.bz2'"
         )
-        action.timeout = 600 + 100 * n_retries
+        action.set_hard_timeout(600 + 100 * n_retries)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -282,7 +282,7 @@ def complete_runtime(
     action = CmdRunAction(
         command=f"{instance['test']['test_cmd']} --json-report --json-report-file=report.json --continue-on-collection-errors {test_dir} > test_output.txt 2>&1"
     )
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -292,7 +292,7 @@ def complete_runtime(
     )
     # Read test output
     action = CmdRunAction(command='cat test_output.txt')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -305,7 +305,7 @@ def complete_runtime(
 
     # Save pytest exit code
     action = CmdRunAction(command='echo $?')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -318,7 +318,7 @@ def complete_runtime(
 
     # Read the test report
     action = CmdRunAction(command='cat report.json')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -330,7 +330,7 @@ def complete_runtime(
     repo_name = instance['repo'].split('/')[1]
     repo_name = repo_name.replace('.', '-')
     action = CmdRunAction(command=f'commit0 get-tests {repo_name}')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
diff --git a/evaluation/benchmarks/swe_bench/eval_infer.py b/evaluation/benchmarks/swe_bench/eval_infer.py
index 7beacf344408..ab7feb38b678 100644
--- a/evaluation/benchmarks/swe_bench/eval_infer.py
+++ b/evaluation/benchmarks/swe_bench/eval_infer.py
@@ -174,7 +174,7 @@ def process_instance(
 
     # Set +x
     action = CmdRunAction(command='chmod +x /tmp/eval.sh')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -189,7 +189,7 @@ def process_instance(
         "echo 'APPLY_PATCH_FAIL')))"
     )
     action = CmdRunAction(command=exec_command)
-    action.timeout = 600
+    action.set_hard_timeout(600)
     obs = runtime.run_action(action)
     assert isinstance(obs, CmdOutputObservation)
     apply_patch_output = obs.content
@@ -212,7 +212,7 @@ def process_instance(
             # Run eval script in background and save output to log file
             log_file = '/tmp/eval_output.log'
             action = CmdRunAction(command=f'/tmp/eval.sh > {log_file} 2>&1 & echo $!')
-            action.timeout = 60  # Short timeout just to get the process ID
+            action.set_hard_timeout(60)  # Short timeout just to get the process ID
             obs = runtime.run_action(action)
 
             if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0:
@@ -235,7 +235,7 @@ def process_instance(
                     check_action = CmdRunAction(
                         command=f'ps -p {pid} > /dev/null; echo $?'
                     )
-                    check_action.timeout = 60
+                    check_action.set_hard_timeout(60)
                     check_obs = runtime.run_action(check_action)
                     if (
                         isinstance(check_obs, CmdOutputObservation)
@@ -252,7 +252,7 @@ def process_instance(
 
                 # Read the log file
                 cat_action = CmdRunAction(command=f'cat {log_file}')
-                cat_action.timeout = 300
+                cat_action.set_hard_timeout(300)
                 cat_obs = runtime.run_action(cat_action)
 
                 # Grade answer
diff --git a/evaluation/benchmarks/swe_bench/run_infer.py b/evaluation/benchmarks/swe_bench/run_infer.py
index bf065ada9734..ac9e85b60b10 100644
--- a/evaluation/benchmarks/swe_bench/run_infer.py
+++ b/evaluation/benchmarks/swe_bench/run_infer.py
@@ -173,7 +173,7 @@ def initialize_runtime(
     action = CmdRunAction(
         command=f"""echo 'export SWE_INSTANCE_ID={instance['instance_id']}' >> ~/.bashrc && echo 'export PIP_CACHE_DIR=~/.cache/pip' >> ~/.bashrc && echo "alias git='git --no-pager'" >> ~/.bashrc"""
     )
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -182,7 +182,7 @@ def initialize_runtime(
     )
 
     action = CmdRunAction(command="""export USER=$(whoami); echo USER=${USER} """)
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -194,7 +194,7 @@ def initialize_runtime(
 
         # inject the instance info
         action = CmdRunAction(command='mkdir -p /swe_util/eval_data/instances')
-        action.timeout = 600
+        action.set_hard_timeout(600)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -223,14 +223,14 @@ def initialize_runtime(
             '/swe_util/',
         )
         action = CmdRunAction(command='cat ~/.bashrc')
-        action.timeout = 600
+        action.set_hard_timeout(600)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert_and_raise(obs.exit_code == 0, f'Failed to cat ~/.bashrc: {str(obs)}')
 
         action = CmdRunAction(command='source ~/.bashrc')
-        action.timeout = 600
+        action.set_hard_timeout(600)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -239,7 +239,7 @@ def initialize_runtime(
         assert_and_raise(obs.exit_code == 0, f'Failed to source ~/.bashrc: {str(obs)}')
 
         action = CmdRunAction(command='source /swe_util/instance_swe_entry.sh')
-        action.timeout = 3600
+        action.set_hard_timeout(3600)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -249,7 +249,7 @@ def initialize_runtime(
         )
     else:
         action = CmdRunAction(command='source /swe_util/swe_entry.sh')
-        action.timeout = 1800
+        action.set_hard_timeout(1800)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -259,7 +259,7 @@ def initialize_runtime(
         )
 
     action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -269,7 +269,7 @@ def initialize_runtime(
     )
 
     action = CmdRunAction(command='git reset --hard')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -278,14 +278,14 @@ def initialize_runtime(
     action = CmdRunAction(
         command='for remote_name in $(git remote); do git remote remove "${remote_name}"; done'
     )
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert_and_raise(obs.exit_code == 0, f'Failed to remove git remotes: {str(obs)}')
 
     action = CmdRunAction(command='which python')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -316,7 +316,7 @@ def complete_runtime(
     workspace_dir_name = _get_swebench_workspace_dir_name(instance)
 
     action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -326,7 +326,7 @@ def complete_runtime(
     )
 
     action = CmdRunAction(command='git config --global core.pager ""')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -336,7 +336,7 @@ def complete_runtime(
     )
 
     action = CmdRunAction(command='git add -A')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -351,7 +351,7 @@ def complete_runtime(
         action = CmdRunAction(
             command=f'git diff --no-color --cached {instance["base_commit"]}'
         )
-        action.timeout = 600 + 100 * n_retries
+        action.set_hard_timeout(600 + 100 * n_retries)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
diff --git a/evaluation/benchmarks/the_agent_company/browsing.py b/evaluation/benchmarks/the_agent_company/browsing.py
index 7384dddbdfce..5ce97129777a 100644
--- a/evaluation/benchmarks/the_agent_company/browsing.py
+++ b/evaluation/benchmarks/the_agent_company/browsing.py
@@ -262,7 +262,7 @@ def pre_login(
             instruction = action.to_instruction()
 
             browser_action = BrowseInteractiveAction(browser_actions=instruction)
-            browser_action.timeout = 10000
+            browser_action.set_hard_timeout(10000)
             logger.info(browser_action, extra={'msg_type': 'ACTION'})
             obs: BrowserOutputObservation = runtime.run_action(browser_action)
             logger.debug(obs, extra={'msg_type': 'OBSERVATION'})
diff --git a/evaluation/benchmarks/the_agent_company/run_infer.py b/evaluation/benchmarks/the_agent_company/run_infer.py
index a82db6d56081..8f8a1b599e6f 100644
--- a/evaluation/benchmarks/the_agent_company/run_infer.py
+++ b/evaluation/benchmarks/the_agent_company/run_infer.py
@@ -86,7 +86,7 @@ def init_task_env(runtime: Runtime, hostname: str, env_llm_config: LLMConfig):
         'bash /utils/init.sh'
     )
     action = CmdRunAction(command=command)
-    action.timeout = 900
+    action.set_hard_timeout(900)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -172,7 +172,7 @@ def run_evaluator(
         f'python_default /utils/eval.py --trajectory_path {trajectory_path} --result_path {result_path}'
     )
     action = CmdRunAction(command=command)
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
diff --git a/openhands/events/event.py b/openhands/events/event.py
index 6c7a2d8a3ac1..1bdece59eb75 100644
--- a/openhands/events/event.py
+++ b/openhands/events/event.py
@@ -64,8 +64,12 @@ def timeout(self) -> int | None:
             return self._timeout  # type: ignore[attr-defined]
         return None
 
-    @timeout.setter
-    def timeout(self, value: int | None) -> None:
+    def set_hard_timeout(self, value: int | None, blocking: bool = True) -> None:
+        """Set the timeout for the event.
+
+        NOTE, this is a hard timeout, meaning that the event will be blocked
+        until the timeout is reached.
+        """
         self._timeout = value
         if value is not None and value > 600:
             from openhands.core.logger import openhands_logger as logger
@@ -78,7 +82,7 @@ def timeout(self, value: int | None) -> None:
         # Check if .blocking is an attribute of the event
         if hasattr(self, 'blocking'):
             # .blocking needs to be set to True if .timeout is set
-            self.blocking = True
+            self.blocking = blocking
 
     # optional metadata, LLM call cost of the edit
     @property
diff --git a/openhands/events/serialization/action.py b/openhands/events/serialization/action.py
index 90945c1d4dfd..be9990750fc6 100644
--- a/openhands/events/serialization/action.py
+++ b/openhands/events/serialization/action.py
@@ -74,7 +74,8 @@ def action_from_dict(action: dict) -> Action:
     try:
         decoded_action = action_class(**args)
         if 'timeout' in action:
-            decoded_action.timeout = action['timeout']
+            blocking = args.get('blocking', False)
+            decoded_action.set_hard_timeout(action['timeout'], blocking=blocking)
 
         # Set timestamp if it was provided
         if timestamp:
diff --git a/openhands/resolver/resolve_issue.py b/openhands/resolver/resolve_issue.py
index f50b37d79447..4e0b2b4ad96c 100644
--- a/openhands/resolver/resolve_issue.py
+++ b/openhands/resolver/resolve_issue.py
@@ -118,7 +118,7 @@ async def complete_runtime(
     git_patch = None
     while n_retries < 5:
         action = CmdRunAction(command=f'git diff --no-color --cached {base_commit}')
-        action.timeout = 600 + 100 * n_retries
+        action.set_hard_timeout(600 + 100 * n_retries)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
diff --git a/openhands/runtime/action_execution_server.py b/openhands/runtime/action_execution_server.py
index b483c183cbdc..8a5fcdc0edd9 100644
--- a/openhands/runtime/action_execution_server.py
+++ b/openhands/runtime/action_execution_server.py
@@ -120,6 +120,9 @@ async def ainit(self):
         self.bash_session = BashSession(
             work_dir=self._initial_cwd,
             username=self.username,
+            no_change_timeout_seconds=int(
+                os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 30)
+            ),
         )
         self.bash_session.initialize()
         await wait_all(
@@ -163,7 +166,7 @@ async def _init_bash_commands(self):
         logger.debug(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
         for command in INIT_COMMANDS:
             action = CmdRunAction(command=command)
-            action.timeout = 300
+            action.set_hard_timeout(300)
             logger.debug(f'Executing init command: {command}')
             obs = await self.run(action)
             assert isinstance(obs, CmdOutputObservation)
diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py
index 114289f390b2..94a059f06aa3 100644
--- a/openhands/runtime/base.py
+++ b/openhands/runtime/base.py
@@ -182,7 +182,8 @@ def on_event(self, event: Event) -> None:
 
     async def _handle_action(self, event: Action) -> None:
         if event.timeout is None:
-            event.timeout = self.config.sandbox.timeout
+            # We don't block the command if this is a default timeout action
+            event.set_hard_timeout(self.config.sandbox.timeout, blocking=False)
         assert event.timeout is not None
         try:
             observation: Observation = await call_sync_from_async(
diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py
index 24fb8250b30e..f8c93dd5561f 100644
--- a/openhands/runtime/impl/action_execution/action_execution_client.py
+++ b/openhands/runtime/impl/action_execution/action_execution_client.py
@@ -216,7 +216,8 @@ def send_action_for_execution(self, action: Action) -> Observation:
 
         # set timeout to default if not set
         if action.timeout is None:
-            action.timeout = self.config.sandbox.timeout
+            # We don't block the command if this is a default timeout action
+            action.set_hard_timeout(self.config.sandbox.timeout, blocking=False)
 
         with self.action_semaphore:
             if not action.runnable:
diff --git a/openhands/runtime/impl/docker/docker_runtime.py b/openhands/runtime/impl/docker/docker_runtime.py
index 5111f0f36831..bf06e00e854f 100644
--- a/openhands/runtime/impl/docker/docker_runtime.py
+++ b/openhands/runtime/impl/docker/docker_runtime.py
@@ -228,6 +228,8 @@ def _init_container(self):
         }
         if self.config.debug or DEBUG:
             environment['DEBUG'] = 'true'
+        # also update with runtime_startup_env_vars
+        environment.update(self.config.sandbox.runtime_startup_env_vars)
 
         self.log('debug', f'Workspace Base: {self.config.workspace_base}')
         if (
diff --git a/openhands/runtime/utils/bash.py b/openhands/runtime/utils/bash.py
index 351d990dcda6..87b2ae405f1d 100644
--- a/openhands/runtime/utils/bash.py
+++ b/openhands/runtime/utils/bash.py
@@ -174,7 +174,7 @@ def __init__(
         self,
         work_dir: str,
         username: str | None = None,
-        no_change_timeout_seconds: float = 30.0,
+        no_change_timeout_seconds: int = 30,
     ):
         self.NO_CHANGE_TIMEOUT_SECONDS = no_change_timeout_seconds
         self.work_dir = work_dir
@@ -369,7 +369,7 @@ def _handle_nochange_timeout_command(
             command,
             raw_command_output,
             metadata,
-            continue_prefix='[Command output continued from previous command]\n',
+            continue_prefix='[Below is the output of the previous command.]\n',
         )
         return CmdOutputObservation(
             content=command_output,
@@ -404,7 +404,7 @@ def _handle_hard_timeout_command(
             command,
             raw_command_output,
             metadata,
-            continue_prefix='[Command output continued from previous command]\n',
+            continue_prefix='[Below is the output of the previous command.]\n',
         )
 
         return CmdOutputObservation(
@@ -441,6 +441,8 @@ def _combine_outputs_between_matches(
             else:
                 # The command output is the content after the last PS1 prompt
                 return pane_content[ps1_matches[0].end() + 1 :]
+        elif len(ps1_matches) == 0:
+            return pane_content
         combined_output = ''
         for i in range(len(ps1_matches) - 1):
             # Extract content between current and next PS1 prompt
@@ -459,6 +461,9 @@ def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservati
         # Strip the command of any leading/trailing whitespace
         logger.debug(f'RECEIVED ACTION: {action}')
         command = action.command.strip()
+        is_special_key = self._is_special_key(command)
+
+        # Handle when prev command is hard timeout
 
         if command == '' and self.prev_status not in {
             BashCommandStatus.CONTINUE,
@@ -486,13 +491,45 @@ def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservati
         last_change_time = start_time
         last_pane_output = self._get_pane_content()
 
-        if command != '':
+        # Do not check hard timeout if the command is a special key
+        if command != '' and is_special_key:
+            logger.debug(f'SENDING SPECIAL KEY: {command!r}')
+            self.pane.send_keys(command, enter=False)
+        # When prev command is hard timeout, and we are trying to execute new command
+        elif self.prev_status == BashCommandStatus.HARD_TIMEOUT and command != '':
+            if not last_pane_output.endswith(CMD_OUTPUT_PS1_END):
+                _ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
+                raw_command_output = self._combine_outputs_between_matches(
+                    last_pane_output, _ps1_matches
+                )
+                metadata = CmdOutputMetadata()  # No metadata available
+                metadata.suffix = (
+                    f'\n[Your command "{command}" is NOT executed. '
+                    f'The previous command was timed out but still running. Above is the output of the previous command. '
+                    "You may wait longer to see additional output of the previous command by sending empty command '', "
+                    'send other commands to interact with the current process, '
+                    'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
+                )
+                command_output = self._get_command_output(
+                    command,
+                    raw_command_output,
+                    metadata,
+                    continue_prefix='[Below is the output of the previous command.]\n',
+                )
+                return CmdOutputObservation(
+                    command=command,
+                    content=command_output,
+                    metadata=metadata,
+                )
+        # Only send the command to the pane if it's not a special key and it's not empty
+        # AND previous hard timeout command is resolved
+        elif command != '' and not is_special_key:
             # convert command to raw string
             command = escape_bash_special_chars(command)
             logger.debug(f'SENDING COMMAND: {command!r}')
             self.pane.send_keys(
                 command,
-                enter=not self._is_special_key(command),
+                enter=True,
             )
 
         # Loop until the command completes or times out
@@ -525,7 +562,7 @@ def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservati
             # We ignore this if the command is *blocking
             time_since_last_change = time.time() - last_change_time
             logger.debug(
-                f'CHECKING NO CHANGE TIMEOUT ({self.NO_CHANGE_TIMEOUT_SECONDS}s): elapsed {time_since_last_change}'
+                f'CHECKING NO CHANGE TIMEOUT ({self.NO_CHANGE_TIMEOUT_SECONDS}s): elapsed {time_since_last_change}. Action blocking: {action.blocking}'
             )
             if (
                 not action.blocking
diff --git a/tests/runtime/test_bash.py b/tests/runtime/test_bash.py
index 3a25fd01ddee..828c859f11dd 100644
--- a/tests/runtime/test_bash.py
+++ b/tests/runtime/test_bash.py
@@ -1,6 +1,7 @@
 """Bash-related tests for the EventStreamRuntime, which connects to the ActionExecutor running in the sandbox."""
 
 import os
+import time
 from pathlib import Path
 
 import pytest
@@ -45,7 +46,7 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
     runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
     try:
         action = CmdRunAction(command='python3 -m http.server 8080')
-        action.timeout = 1
+        action.set_hard_timeout(1)
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert isinstance(obs, CmdOutputObservation)
@@ -57,7 +58,7 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
         )
 
         action = CmdRunAction(command='C-c')
-        action.timeout = 30
+        action.set_hard_timeout(30)
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert isinstance(obs, CmdOutputObservation)
@@ -66,7 +67,7 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
         assert '/workspace' in obs.metadata.working_dir
 
         action = CmdRunAction(command='ls')
-        action.timeout = 1
+        action.set_hard_timeout(1)
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert isinstance(obs, CmdOutputObservation)
@@ -76,7 +77,7 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
 
         # run it again!
         action = CmdRunAction(command='python3 -m http.server 8080')
-        action.timeout = 1
+        action.set_hard_timeout(1)
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert isinstance(obs, CmdOutputObservation)
@@ -555,15 +556,20 @@ def test_basic_command(temp_dir, runtime_cls, run_as_openhands):
 
 
 def test_interactive_command(temp_dir, runtime_cls, run_as_openhands):
-    runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
+    runtime = _load_runtime(
+        temp_dir,
+        runtime_cls,
+        run_as_openhands,
+        runtime_startup_env_vars={'NO_CHANGE_TIMEOUT_SECONDS': '1'},
+    )
     try:
         # Test interactive command
         action = CmdRunAction('read -p "Enter name: " name && echo "Hello $name"')
-        action.timeout = 1
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
-        # assert 'Enter name:' in obs.content # FIXME: this is not working
-        assert '[The command timed out after 1 seconds.' in obs.metadata.suffix
+        # This should trigger SOFT timeout, so no need to set hard timeout
+        assert 'Enter name:' in obs.content
+        assert '[The command has no new output after 1 seconds.' in obs.metadata.suffix
 
         action = CmdRunAction('John')
         obs = runtime.run_action(action)
@@ -590,7 +596,7 @@ def test_long_output(temp_dir, runtime_cls, run_as_openhands):
     try:
         # Generate a long output
         action = CmdRunAction('for i in $(seq 1 5000); do echo "Line $i"; done')
-        action.timeout = 10
+        action.set_hard_timeout(10)
         obs = runtime.run_action(action)
         assert obs.exit_code == 0
         assert 'Line 1' in obs.content
@@ -604,7 +610,7 @@ def test_long_output_exceed_history_limit(temp_dir, runtime_cls, run_as_openhand
     try:
         # Generate a long output
         action = CmdRunAction('for i in $(seq 1 50000); do echo "Line $i"; done')
-        action.timeout = 30
+        action.set_hard_timeout(30)
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert obs.exit_code == 0
@@ -621,13 +627,13 @@ def test_long_output_from_nested_directories(temp_dir, runtime_cls, run_as_openh
         # Create nested directories with many files
         setup_cmd = 'mkdir -p /tmp/test_dir && cd /tmp/test_dir && for i in $(seq 1 100); do mkdir -p "folder_$i"; for j in $(seq 1 100); do touch "folder_$i/file_$j.txt"; done; done'
         setup_action = CmdRunAction(setup_cmd.strip())
-        setup_action.timeout = 60
+        setup_action.set_hard_timeout(60)
         obs = runtime.run_action(setup_action)
         assert obs.exit_code == 0
 
         # List the directory structure recursively
         action = CmdRunAction('ls -R /tmp/test_dir')
-        action.timeout = 60
+        action.set_hard_timeout(60)
         obs = runtime.run_action(action)
         assert obs.exit_code == 0
 
@@ -672,7 +678,7 @@ def test_command_output_continuation(temp_dir, runtime_cls, run_as_openhands):
     try:
         # Start a command that produces output slowly
         action = CmdRunAction('for i in {1..5}; do echo $i; sleep 3; done')
-        action.timeout = 2.5  # Set timeout to 2.5 seconds
+        action.set_hard_timeout(2.5)
         obs = runtime.run_action(action)
         assert obs.content.strip() == '1'
         assert obs.metadata.prefix == ''
@@ -680,20 +686,19 @@ def test_command_output_continuation(temp_dir, runtime_cls, run_as_openhands):
 
         # Continue watching output
         action = CmdRunAction('')
-        action.timeout = 2.5
+        action.set_hard_timeout(2.5)
         obs = runtime.run_action(action)
-        assert '[Command output continued from previous command]' in obs.metadata.prefix
+        assert '[Below is the output of the previous command.]' in obs.metadata.prefix
         assert obs.content.strip() == '2'
         assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
 
         # Continue until completion
         for expected in ['3', '4', '5']:
             action = CmdRunAction('')
-            action.timeout = 2.5
+            action.set_hard_timeout(2.5)
             obs = runtime.run_action(action)
             assert (
-                '[Command output continued from previous command]'
-                in obs.metadata.prefix
+                '[Below is the output of the previous command.]' in obs.metadata.prefix
             )
             assert obs.content.strip() == expected
             assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
@@ -713,8 +718,7 @@ def test_long_running_command_follow_by_execute(
     try:
         # Test command that produces output slowly
         action = CmdRunAction('for i in {1..3}; do echo $i; sleep 3; done')
-        action.timeout = 2.5
-        action.blocking = False
+        action.set_hard_timeout(2.5)
         obs = runtime.run_action(action)
         assert '1' in obs.content  # First number should appear before timeout
         assert obs.metadata.exit_code == -1  # -1 indicates command is still running
@@ -723,25 +727,32 @@ def test_long_running_command_follow_by_execute(
 
         # Continue watching output
         action = CmdRunAction('')
-        action.timeout = 2.5
+        action.set_hard_timeout(2.5)
         obs = runtime.run_action(action)
         assert '2' in obs.content
-        assert (
-            obs.metadata.prefix == '[Command output continued from previous command]\n'
-        )
+        assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
         assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
         assert obs.metadata.exit_code == -1  # -1 indicates command is still running
 
         # Test command that produces no output
         action = CmdRunAction('sleep 15')
-        action.timeout = 2.5
+        action.set_hard_timeout(2.5)
         obs = runtime.run_action(action)
-        assert '3' in obs.content
+        logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert '3' not in obs.content
+        assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
         assert (
-            obs.metadata.prefix == '[Command output continued from previous command]\n'
+            'The previous command was timed out but still running.'
+            in obs.metadata.suffix
         )
-        assert '[The command timed out after 2.5 seconds.' in obs.metadata.suffix
         assert obs.metadata.exit_code == -1  # -1 indicates command is still running
+
+        # Finally continue again
+        action = CmdRunAction('')
+        obs = runtime.run_action(action)
+        logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert '3' in obs.content
+        assert '[The command completed with exit code 0.]' in obs.metadata.suffix
     finally:
         _close_test_runtime(runtime)
 
@@ -783,3 +794,96 @@ def test_python_interactive_input(temp_dir, runtime_cls, run_as_openhands):
         assert '[The command completed with exit code 0.]' in obs.metadata.suffix
     finally:
         _close_test_runtime(runtime)
+
+
+def test_stress_long_output_with_soft_and_hard_timeout(
+    temp_dir, runtime_cls, run_as_openhands
+):
+    runtime = _load_runtime(
+        temp_dir,
+        runtime_cls,
+        run_as_openhands,
+        runtime_startup_env_vars={'NO_CHANGE_TIMEOUT_SECONDS': '1'},
+        docker_runtime_kwargs={
+            'cpu_period': 100000,  # 100ms
+            'cpu_quota': 100000,  # Can use 100ms out of each 100ms period (1 CPU)
+            'mem_limit': '4G',  # 4 GB of memory
+        },
+    )
+    try:
+        # Run a command that generates long output multiple times
+        for i in range(10):
+            start_time = time.time()
+
+            # Check tmux memory usage (in KB)
+            mem_action = CmdRunAction(
+                'ps aux | awk \'{printf "%8.1f KB  %s\\n", $6, $0}\' | sort -nr | grep "/usr/bin/tmux" | grep -v grep | awk \'{print $1}\''
+            )
+            mem_obs = runtime.run_action(mem_action)
+            assert mem_obs.exit_code == 0
+            logger.info(
+                f'Tmux memory usage (iteration {i}): {mem_obs.content.strip()} KB'
+            )
+
+            # Check action_execution_server mem
+            mem_action = CmdRunAction(
+                'ps aux | awk \'{printf "%8.1f KB  %s\\n", $6, $0}\' | sort -nr | grep "action_execution_server" | grep "/openhands/poetry" | grep -v grep | awk \'{print $1}\''
+            )
+            mem_obs = runtime.run_action(mem_action)
+            assert mem_obs.exit_code == 0
+            logger.info(
+                f'Action execution server memory usage (iteration {i}): {mem_obs.content.strip()} KB'
+            )
+
+            # Test soft timeout
+            action = CmdRunAction(
+                'read -p "Do you want to continue? [Y/n] " answer; if [[ $answer == "Y" ]]; then echo "Proceeding with operation..."; echo "Operation completed successfully!"; else echo "Operation cancelled."; exit 1; fi'
+            )
+            obs = runtime.run_action(action)
+            assert 'Do you want to continue?' in obs.content
+            assert obs.exit_code == -1  # Command is still running, waiting for input
+
+            # Send the confirmation
+            action = CmdRunAction('Y')
+            obs = runtime.run_action(action)
+            assert 'Proceeding with operation...' in obs.content
+            assert 'Operation completed successfully!' in obs.content
+            assert obs.exit_code == 0
+            assert '[The command completed with exit code 0.]' in obs.metadata.suffix
+
+            # Test hard timeout w/ long output
+            # Generate long output with 1000 asterisks per line
+            action = CmdRunAction(
+                f'export i={i}; for j in $(seq 1 100); do echo "Line $j - Iteration $i - $(printf \'%1000s\' | tr " " "*")"; sleep 1; done'
+            )
+            action.set_hard_timeout(2)
+            obs = runtime.run_action(action)
+
+            # Verify the output
+            assert obs.exit_code == -1
+            assert f'Line 1 - Iteration {i}' in obs.content
+            # assert f'Line 1000 - Iteration {i}' in obs.content
+            # assert '[The command completed with exit code 0.]' in obs.metadata.suffix
+
+            # Because hard-timeout is triggered, the terminal will in a weird state
+            # where it will not accept any new commands.
+            obs = runtime.run_action(CmdRunAction('ls'))
+            assert obs.exit_code == -1
+            assert (
+                'The previous command was timed out but still running.'
+                in obs.metadata.suffix
+            )
+
+            # We need to send a Ctrl+C to reset the terminal.
+            obs = runtime.run_action(CmdRunAction('C-c'))
+            assert obs.exit_code == 130
+
+            # Now make sure the terminal is in a good state
+            obs = runtime.run_action(CmdRunAction('ls'))
+            assert obs.exit_code == 0
+
+            duration = time.time() - start_time
+            logger.info(f'Completed iteration {i} in {duration:.2f} seconds')
+
+    finally:
+        _close_test_runtime(runtime)
diff --git a/tests/runtime/test_stress_docker_runtime.py b/tests/runtime/test_stress_docker_runtime.py
index d0e141ee3142..6e8a9d5957e8 100644
--- a/tests/runtime/test_stress_docker_runtime.py
+++ b/tests/runtime/test_stress_docker_runtime.py
@@ -28,7 +28,7 @@ def test_stress_docker_runtime(temp_dir, runtime_cls, repeat=1):
     for _ in range(repeat):
         # run stress-ng stress tests for 1 minute
         action = CmdRunAction(command='stress-ng --all 1 -t 1m')
-        action.timeout = 120
+        action.set_hard_timeout(120)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
diff --git a/tests/runtime/test_stress_remote_runtime.py b/tests/runtime/test_stress_remote_runtime.py
index 367af20467be..a2f6c7d2082b 100644
--- a/tests/runtime/test_stress_remote_runtime.py
+++ b/tests/runtime/test_stress_remote_runtime.py
@@ -92,14 +92,14 @@ def initialize_runtime(
     obs: CmdOutputObservation
 
     action = CmdRunAction(command="""export USER=$(whoami); echo USER=${USER} """)
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert_and_raise(obs.exit_code == 0, f'Failed to export USER: {str(obs)}')
 
     action = CmdRunAction(command='mkdir -p /dummy_dir')
-    action.timeout = 600
+    action.set_hard_timeout(600)
     logger.info(action, extra={'msg_type': 'ACTION'})
     obs = runtime.run_action(action)
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
diff --git a/tests/unit/test_bash_session.py b/tests/unit/test_bash_session.py
index 13f32ad27d25..fc29eaffb2a5 100644
--- a/tests/unit/test_bash_session.py
+++ b/tests/unit/test_bash_session.py
@@ -94,7 +94,7 @@ def test_long_running_command_follow_by_execute():
     obs = session.execute(CmdRunAction(''))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '2' in obs.content
-    assert obs.metadata.prefix == '[Command output continued from previous command]\n'
+    assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
     assert obs.metadata.suffix == (
         '\n[The command has no new output after 2 seconds. '
         "You may wait longer to see additional output by sending empty command '', "
@@ -108,7 +108,7 @@ def test_long_running_command_follow_by_execute():
     obs = session.execute(CmdRunAction('sleep 15'))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '3' in obs.content
-    assert obs.metadata.prefix == '[Command output continued from previous command]\n'
+    assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
     assert obs.metadata.suffix == (
         '\n[The command has no new output after 2 seconds. '
         "You may wait longer to see additional output by sending empty command '', "
@@ -175,7 +175,7 @@ def test_interactive_command():
         'send other commands to interact with the current process, '
         'or send keys to interrupt/kill the command.]'
     )
-    assert obs.metadata.prefix == '[Command output continued from previous command]\n'
+    assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
 
     obs = session.execute(CmdRunAction('line 2'))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -187,7 +187,7 @@ def test_interactive_command():
         'send other commands to interact with the current process, '
         'or send keys to interrupt/kill the command.]'
     )
-    assert obs.metadata.prefix == '[Command output continued from previous command]\n'
+    assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
 
     obs = session.execute(CmdRunAction('EOF'))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -266,14 +266,14 @@ def test_command_output_continuation():
 
     obs = session.execute(CmdRunAction(''))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
-    assert '[Command output continued from previous command]' in obs.metadata.prefix
+    assert '[Below is the output of the previous command.]' in obs.metadata.prefix
     assert obs.content.strip() == '2'
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
     obs = session.execute(CmdRunAction(''))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
-    assert '[Command output continued from previous command]' in obs.metadata.prefix
+    assert '[Below is the output of the previous command.]' in obs.metadata.prefix
     assert obs.content.strip() == '3'
 
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
@@ -281,14 +281,14 @@ def test_command_output_continuation():
 
     obs = session.execute(CmdRunAction(''))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
-    assert '[Command output continued from previous command]' in obs.metadata.prefix
+    assert '[Below is the output of the previous command.]' in obs.metadata.prefix
     assert obs.content.strip() == '4'
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
     obs = session.execute(CmdRunAction(''))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
-    assert '[Command output continued from previous command]' in obs.metadata.prefix
+    assert '[Below is the output of the previous command.]' in obs.metadata.prefix
     assert obs.content.strip() == '5'
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
diff --git a/tests/unit/test_runtime_reboot.py b/tests/unit/test_runtime_reboot.py
index e3ae31815a3e..c78fbe029ad6 100644
--- a/tests/unit/test_runtime_reboot.py
+++ b/tests/unit/test_runtime_reboot.py
@@ -27,7 +27,7 @@ def runtime(mock_session):
 def test_runtime_timeout_error(runtime, mock_session):
     # Create a command action
     action = CmdRunAction(command='test command')
-    action.timeout = 120
+    action.set_hard_timeout(120)
 
     # Mock the runtime to raise a timeout error
     runtime.send_action_for_execution.side_effect = AgentRuntimeTimeoutError(
@@ -78,7 +78,7 @@ def test_runtime_disconnected_error(
 
     # Create a command action
     action = CmdRunAction(command='test command')
-    action.timeout = 120
+    action.set_hard_timeout(120)
 
     # Verify that the error message is correct
     with pytest.raises(AgentRuntimeDisconnectedError) as exc_info:

From 0c961bfd8b8e4c89772417f6b9619954dd055181 Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Thu, 16 Jan 2025 12:53:10 -0500
Subject: [PATCH 10/39] refactor(prompt): move runtime/repo info to user
 message and disable them in eval (#6291)

---
 config.template.toml                          |  4 +-
 .../current/usage/configuration-options.md    |  2 +-
 docs/modules/usage/configuration-options.md   |  2 +-
 evaluation/benchmarks/EDA/run_infer.py        |  2 +-
 .../benchmarks/agent_bench/run_infer.py       |  2 +-
 .../benchmarks/aider_bench/run_infer.py       |  2 +-
 evaluation/benchmarks/biocoder/run_infer.py   |  2 +-
 evaluation/benchmarks/bird/run_infer.py       |  2 +-
 .../browsing_delegation/run_infer.py          |  2 +-
 .../benchmarks/discoverybench/run_infer.py    |  2 +-
 evaluation/benchmarks/gaia/run_infer.py       |  2 +-
 evaluation/benchmarks/gorilla/run_infer.py    |  2 +-
 evaluation/benchmarks/gpqa/run_infer.py       |  2 +-
 .../benchmarks/humanevalfix/run_infer.py      |  2 +-
 .../benchmarks/logic_reasoning/run_infer.py   |  2 +-
 evaluation/benchmarks/mint/run_infer.py       |  2 +-
 evaluation/benchmarks/ml_bench/run_infer.py   |  2 +-
 evaluation/benchmarks/toolqa/run_infer.py     |  2 +-
 evaluation/benchmarks/webarena/run_infer.py   |  2 +-
 .../agenthub/codeact_agent/codeact_agent.py   | 13 +++++-
 .../codeact_agent/prompts/system_prompt.j2    | 22 ----------
 openhands/core/config/agent_config.py         |  4 +-
 openhands/utils/prompt.py                     | 43 ++++++++++++++++++-
 tests/unit/test_codeact_agent.py              |  4 +-
 tests/unit/test_prompt_manager.py             | 22 ++++------
 25 files changed, 87 insertions(+), 61 deletions(-)

diff --git a/config.template.toml b/config.template.toml
index aefb52376803..ccb7b1159747 100644
--- a/config.template.toml
+++ b/config.template.toml
@@ -223,8 +223,8 @@ codeact_enable_jupyter = true
 # LLM config group to use
 #llm_config = 'your-llm-config-group'
 
-# Whether to use microagents at all
-#use_microagents = true
+# Whether to use prompt extension (e.g., microagent, repo/runtime info) at all
+#enable_prompt_extensions = true
 
 # List of microagents to disable
 #disabled_microagents = []
diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/configuration-options.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/configuration-options.md
index 848b85a53164..b79a65073acc 100644
--- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/configuration-options.md
+++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/configuration-options.md
@@ -373,7 +373,7 @@ Agent 配置选项在 `config.toml` 文件的 `[agent]` 和 `[agent.<agent_name>
   - 描述: 是否在 action space 中启用 Jupyter
 
 **Microagent 使用**
-- `use_microagents`
+- `enable_prompt_extensions`
   - 类型: `bool`
   - 默认值: `true`
   - 描述: 是否使用 microagents
diff --git a/docs/modules/usage/configuration-options.md b/docs/modules/usage/configuration-options.md
index 422dc1cc4913..a3c11de52ed8 100644
--- a/docs/modules/usage/configuration-options.md
+++ b/docs/modules/usage/configuration-options.md
@@ -336,7 +336,7 @@ The agent configuration options are defined in the `[agent]` and `[agent.<agent_
   - Description: Whether Jupyter is enabled in the action space
 
 ### Microagent Usage
-- `use_microagents`
+- `enable_prompt_extensions`
   - Type: `bool`
   - Default: `true`
   - Description: Whether to use microagents at all
diff --git a/evaluation/benchmarks/EDA/run_infer.py b/evaluation/benchmarks/EDA/run_infer.py
index b5a021a0b853..26756d3ea5e9 100644
--- a/evaluation/benchmarks/EDA/run_infer.py
+++ b/evaluation/benchmarks/EDA/run_infer.py
@@ -76,7 +76,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/agent_bench/run_infer.py b/evaluation/benchmarks/agent_bench/run_infer.py
index 554ddc66488f..fb221e15ba44 100644
--- a/evaluation/benchmarks/agent_bench/run_infer.py
+++ b/evaluation/benchmarks/agent_bench/run_infer.py
@@ -60,7 +60,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/aider_bench/run_infer.py b/evaluation/benchmarks/aider_bench/run_infer.py
index 51179724e239..926f4634ed40 100644
--- a/evaluation/benchmarks/aider_bench/run_infer.py
+++ b/evaluation/benchmarks/aider_bench/run_infer.py
@@ -68,7 +68,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
 
     # copy 'draft_editor' config if exists
     config_copy = copy.deepcopy(config)
diff --git a/evaluation/benchmarks/biocoder/run_infer.py b/evaluation/benchmarks/biocoder/run_infer.py
index dfc0eaf1f912..dc6dc8fd3b59 100644
--- a/evaluation/benchmarks/biocoder/run_infer.py
+++ b/evaluation/benchmarks/biocoder/run_infer.py
@@ -74,7 +74,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/bird/run_infer.py b/evaluation/benchmarks/bird/run_infer.py
index 3254e4a2c791..8570e07b6609 100644
--- a/evaluation/benchmarks/bird/run_infer.py
+++ b/evaluation/benchmarks/bird/run_infer.py
@@ -87,7 +87,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/browsing_delegation/run_infer.py b/evaluation/benchmarks/browsing_delegation/run_infer.py
index 3313c9ff4c3d..33d6ef4805fa 100644
--- a/evaluation/benchmarks/browsing_delegation/run_infer.py
+++ b/evaluation/benchmarks/browsing_delegation/run_infer.py
@@ -51,7 +51,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/discoverybench/run_infer.py b/evaluation/benchmarks/discoverybench/run_infer.py
index 05ff44003517..30af2d19d473 100644
--- a/evaluation/benchmarks/discoverybench/run_infer.py
+++ b/evaluation/benchmarks/discoverybench/run_infer.py
@@ -78,7 +78,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     agent_config = AgentConfig(
         function_calling=False,
         codeact_enable_jupyter=True,
diff --git a/evaluation/benchmarks/gaia/run_infer.py b/evaluation/benchmarks/gaia/run_infer.py
index 7974a092903c..b4c704e497f7 100644
--- a/evaluation/benchmarks/gaia/run_infer.py
+++ b/evaluation/benchmarks/gaia/run_infer.py
@@ -63,7 +63,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/gorilla/run_infer.py b/evaluation/benchmarks/gorilla/run_infer.py
index 740a3c3ada8f..e97be5ed836c 100644
--- a/evaluation/benchmarks/gorilla/run_infer.py
+++ b/evaluation/benchmarks/gorilla/run_infer.py
@@ -56,7 +56,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/gpqa/run_infer.py b/evaluation/benchmarks/gpqa/run_infer.py
index eb1c808ec8a4..cf4106b97136 100644
--- a/evaluation/benchmarks/gpqa/run_infer.py
+++ b/evaluation/benchmarks/gpqa/run_infer.py
@@ -77,7 +77,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/humanevalfix/run_infer.py b/evaluation/benchmarks/humanevalfix/run_infer.py
index ba802ddf9dfa..fec040079cc6 100644
--- a/evaluation/benchmarks/humanevalfix/run_infer.py
+++ b/evaluation/benchmarks/humanevalfix/run_infer.py
@@ -98,7 +98,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/logic_reasoning/run_infer.py b/evaluation/benchmarks/logic_reasoning/run_infer.py
index ee48f5ea76c8..acd07edef26e 100644
--- a/evaluation/benchmarks/logic_reasoning/run_infer.py
+++ b/evaluation/benchmarks/logic_reasoning/run_infer.py
@@ -62,7 +62,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/mint/run_infer.py b/evaluation/benchmarks/mint/run_infer.py
index 61223572ae83..ddfef0ea685b 100644
--- a/evaluation/benchmarks/mint/run_infer.py
+++ b/evaluation/benchmarks/mint/run_infer.py
@@ -120,7 +120,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/ml_bench/run_infer.py b/evaluation/benchmarks/ml_bench/run_infer.py
index 4e396b3c3fe1..c2fcc1ae3e26 100644
--- a/evaluation/benchmarks/ml_bench/run_infer.py
+++ b/evaluation/benchmarks/ml_bench/run_infer.py
@@ -93,7 +93,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/toolqa/run_infer.py b/evaluation/benchmarks/toolqa/run_infer.py
index 8586f9a7bb7c..8306292d8f2f 100644
--- a/evaluation/benchmarks/toolqa/run_infer.py
+++ b/evaluation/benchmarks/toolqa/run_infer.py
@@ -57,7 +57,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/evaluation/benchmarks/webarena/run_infer.py b/evaluation/benchmarks/webarena/run_infer.py
index c35c79ba2cce..79b7fc4371aa 100644
--- a/evaluation/benchmarks/webarena/run_infer.py
+++ b/evaluation/benchmarks/webarena/run_infer.py
@@ -78,7 +78,7 @@ def get_config(
     )
     config.set_llm_config(metadata.llm_config)
     agent_config = config.get_agent_config(metadata.agent_class)
-    agent_config.use_microagents = False
+    agent_config.enable_prompt_extensions = False
     return config
 
 
diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py
index d8b5702a235d..37c52855148a 100644
--- a/openhands/agenthub/codeact_agent/codeact_agent.py
+++ b/openhands/agenthub/codeact_agent/codeact_agent.py
@@ -111,7 +111,7 @@ def __init__(
                 os.path.dirname(os.path.dirname(openhands.__file__)),
                 'microagents',
             )
-            if self.config.use_microagents
+            if self.config.enable_prompt_extensions
             else None,
             prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
             disabled_microagents=self.config.disabled_microagents,
@@ -448,6 +448,17 @@ def _get_messages(self, state: State) -> list[Message]:
                 )
             )
 
+        # Repository and runtime info
+        additional_info = self.prompt_manager.get_additional_info()
+        if self.config.enable_prompt_extensions and additional_info:
+            # only add these if prompt extension is enabled
+            messages.append(
+                Message(
+                    role='user',
+                    content=[TextContent(text=additional_info)],
+                )
+            )
+
         pending_tool_call_action_messages: dict[str, Message] = {}
         tool_call_id_to_message: dict[str, Message] = {}
 
diff --git a/openhands/agenthub/codeact_agent/prompts/system_prompt.j2 b/openhands/agenthub/codeact_agent/prompts/system_prompt.j2
index b6dfcd9bda75..325392f2e662 100644
--- a/openhands/agenthub/codeact_agent/prompts/system_prompt.j2
+++ b/openhands/agenthub/codeact_agent/prompts/system_prompt.j2
@@ -3,26 +3,4 @@ You are OpenHands agent, a helpful AI assistant that can interact with a compute
 * If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
 * When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
 * The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
-{{ runtime_info }}
 </IMPORTANT>
-{% if repository_info %}
-<REPOSITORY_INFO>
-At the user's request, repository {{ repository_info.repo_name }} has been cloned to directory {{ repository_info.repo_directory }}.
-</REPOSITORY_INFO>
-{% endif %}
-{% if repository_instructions -%}
-<REPOSITORY_INSTRUCTIONS>
-{{ repository_instructions }}
-</REPOSITORY_INSTRUCTIONS>
-{% endif %}
-{% if runtime_info and runtime_info.available_hosts -%}
-<RUNTIME_INFORMATION>
-The user has access to the following hosts for accessing a web application,
-each of which has a corresponding port:
-{% for host, port in runtime_info.available_hosts.items() -%}
-* {{ host }} (port {{ port }})
-{% endfor %}
-When starting a web server, use the corresponding ports. You should also
-set any options to allow iframes and CORS requests.
-</RUNTIME_INFORMATION>
-{% endif %}
diff --git a/openhands/core/config/agent_config.py b/openhands/core/config/agent_config.py
index 77e9dbc1e32d..375fd9b12e8a 100644
--- a/openhands/core/config/agent_config.py
+++ b/openhands/core/config/agent_config.py
@@ -17,7 +17,7 @@ class AgentConfig:
         memory_enabled: Whether long-term memory (embeddings) is enabled.
         memory_max_threads: The maximum number of threads indexing at the same time for embeddings.
         llm_config: The name of the llm config to use. If specified, this will override global llm config.
-        use_microagents: Whether to use microagents at all. Default is True.
+        enable_prompt_extensions: Whether to use prompt extensions (e.g., microagents, inject runtime info). Default is True.
         disabled_microagents: A list of microagents to disable. Default is None.
         condenser: Configuration for the memory condenser. Default is NoOpCondenserConfig.
     """
@@ -29,7 +29,7 @@ class AgentConfig:
     memory_enabled: bool = False
     memory_max_threads: int = 3
     llm_config: str | None = None
-    use_microagents: bool = True
+    enable_prompt_extensions: bool = True
     disabled_microagents: list[str] | None = None
     condenser: CondenserConfig = field(default_factory=NoOpCondenserConfig)  # type: ignore
 
diff --git a/openhands/utils/prompt.py b/openhands/utils/prompt.py
index 8d81cbbdf9d6..1861c45308b5 100644
--- a/openhands/utils/prompt.py
+++ b/openhands/utils/prompt.py
@@ -28,6 +28,33 @@ class RepositoryInfo:
     repo_directory: str | None = None
 
 
+ADDITIONAL_INFO_TEMPLATE = Template(
+    """
+{% if repository_info %}
+<REPOSITORY_INFO>
+At the user's request, repository {{ repository_info.repo_name }} has been cloned to directory {{ repository_info.repo_directory }}.
+</REPOSITORY_INFO>
+{% endif %}
+{% if repository_instructions -%}
+<REPOSITORY_INSTRUCTIONS>
+{{ repository_instructions }}
+</REPOSITORY_INSTRUCTIONS>
+{% endif %}
+{% if runtime_info and runtime_info.available_hosts -%}
+<RUNTIME_INFORMATION>
+The user has access to the following hosts for accessing a web application,
+each of which has a corresponding port:
+{% for host, port in runtime_info.available_hosts.items() -%}
+* {{ host }} (port {{ port }})
+{% endfor %}
+When starting a web server, use the corresponding ports. You should also
+set any options to allow iframes and CORS requests.
+</RUNTIME_INFORMATION>
+{% endif %}
+"""
+)
+
+
 class PromptManager:
     """
     Manages prompt templates and micro-agents for AI interactions.
@@ -59,6 +86,9 @@ def __init__(
         self.repo_microagents: dict[str, RepoMicroAgent] = {}
 
         if microagent_dir:
+            # This loads micro-agents from the microagent_dir
+            # which is typically the OpenHands/microagents (i.e., the PUBLIC microagents)
+
             # Only load KnowledgeMicroAgents
             repo_microagents, knowledge_microagents, _ = load_microagents_from_dir(
                 microagent_dir
@@ -79,6 +109,10 @@ def __init__(
                     self.repo_microagents[name] = microagent
 
     def load_microagents(self, microagents: list[BaseMicroAgent]):
+        """Load microagents from a list of BaseMicroAgents.
+
+        This is typically used when loading microagents from inside a repo.
+        """
         # Only keep KnowledgeMicroAgents and RepoMicroAgents
         for microagent in microagents:
             if microagent.name in self.disabled_microagents:
@@ -98,6 +132,13 @@ def _load_template(self, template_name: str) -> Template:
             return Template(file.read())
 
     def get_system_message(self) -> str:
+        return self.system_template.render().strip()
+
+    def get_additional_info(self) -> str:
+        """Gets information about the repository and runtime.
+
+        This is used to inject information about the repository and runtime into the initial user message.
+        """
         repo_instructions = ''
         assert (
             len(self.repo_microagents) <= 1
@@ -108,7 +149,7 @@ def get_system_message(self) -> str:
                 repo_instructions += '\n\n'
             repo_instructions += microagent.content
 
-        return self.system_template.render(
+        return ADDITIONAL_INFO_TEMPLATE.render(
             repository_instructions=repo_instructions,
             repository_info=self.repository_info,
             runtime_info=self.runtime_info,
diff --git a/tests/unit/test_codeact_agent.py b/tests/unit/test_codeact_agent.py
index b1f5e420c3b4..84f0b8fc1993 100644
--- a/tests/unit/test_codeact_agent.py
+++ b/tests/unit/test_codeact_agent.py
@@ -471,7 +471,7 @@ def test_mock_function_calling():
     llm = Mock()
     llm.is_function_calling_active = lambda: False
     config = AgentConfig()
-    config.use_microagents = False
+    config.enable_prompt_extensions = False
     agent = CodeActAgent(llm=llm, config=config)
     assert agent.mock_function_calling is True
 
@@ -509,7 +509,7 @@ def test_step_with_no_pending_actions(mock_state: State):
 
     # Create agent with mocked LLM
     config = AgentConfig()
-    config.use_microagents = False
+    config.enable_prompt_extensions = False
     agent = CodeActAgent(llm=llm, config=config)
 
     # Test step with no pending actions
diff --git a/tests/unit/test_prompt_manager.py b/tests/unit/test_prompt_manager.py
index 4f2a69f7f0d5..46f1f5a254a1 100644
--- a/tests/unit/test_prompt_manager.py
+++ b/tests/unit/test_prompt_manager.py
@@ -59,9 +59,10 @@ def test_prompt_manager_with_microagent(prompt_dir):
     # Test with GitHub repo
     manager.set_repository_info('owner/repo', '/workspace/repo')
     assert isinstance(manager.get_system_message(), str)
-    assert '<REPOSITORY_INFO>' in manager.get_system_message()
-    assert 'owner/repo' in manager.get_system_message()
-    assert '/workspace/repo' in manager.get_system_message()
+    additional_info = manager.get_additional_info()
+    assert '<REPOSITORY_INFO>' in additional_info
+    assert 'owner/repo' in additional_info
+    assert '/workspace/repo' in additional_info
 
     assert isinstance(manager.get_example_user_message(), str)
 
@@ -85,13 +86,7 @@ def test_prompt_manager_file_not_found(prompt_dir):
 def test_prompt_manager_template_rendering(prompt_dir):
     # Create temporary template files
     with open(os.path.join(prompt_dir, 'system_prompt.j2'), 'w') as f:
-        f.write("""System prompt: bar
-{% if repository_info %}
-<REPOSITORY_INFO>
-At the user's request, repository {{ repository_info.repo_name }} has been cloned to directory {{ repository_info.repo_directory }}.
-</REPOSITORY_INFO>
-{% endif %}
-{{ repo_instructions }}""")
+        f.write("""System prompt: bar""")
     with open(os.path.join(prompt_dir, 'user_prompt.j2'), 'w') as f:
         f.write('User prompt: foo')
 
@@ -106,12 +101,13 @@ def test_prompt_manager_template_rendering(prompt_dir):
     assert manager.repository_info.repo_name == 'owner/repo'
     system_msg = manager.get_system_message()
     assert 'System prompt: bar' in system_msg
-    assert '<REPOSITORY_INFO>' in system_msg
+    additional_info = manager.get_additional_info()
+    assert '<REPOSITORY_INFO>' in additional_info
     assert (
         "At the user's request, repository owner/repo has been cloned to directory /workspace/repo."
-        in system_msg
+        in additional_info
     )
-    assert '</REPOSITORY_INFO>' in system_msg
+    assert '</REPOSITORY_INFO>' in additional_info
     assert manager.get_example_user_message() == 'User prompt: foo'
 
     # Clean up temporary files

From 0661c69bd3b287e124c6f22ca6e69064f5b97561 Mon Sep 17 00:00:00 2001
From: OpenHands <opendevin@all-hands.dev>
Date: Fri, 17 Jan 2025 03:43:55 +0900
Subject: [PATCH 11/39] Fix issue #6273: [Feature]: Disable LitLLM Print
 Message (#6274)

Co-authored-by: Xingyao Wang <xingyao6@illinois.edu>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
---
 openhands/core/logger.py          | 21 ++++++++++++
 pyproject.toml                    |  2 ++
 tests/unit/test_logger_litellm.py | 55 +++++++++++++++++++++++++++++++
 3 files changed, 78 insertions(+)
 create mode 100644 tests/unit/test_logger_litellm.py

diff --git a/openhands/core/logger.py b/openhands/core/logger.py
index d6bd0ffd9253..7b7fd89a97a5 100644
--- a/openhands/core/logger.py
+++ b/openhands/core/logger.py
@@ -8,10 +8,31 @@
 from types import TracebackType
 from typing import Any, Literal, Mapping
 
+import litellm
 from termcolor import colored
 
 LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
 DEBUG = os.getenv('DEBUG', 'False').lower() in ['true', '1', 'yes']
+DEBUG_LLM = os.getenv('DEBUG_LLM', 'False').lower() in ['true', '1', 'yes']
+
+# Configure litellm logging based on DEBUG_LLM
+if DEBUG_LLM:
+    confirmation = input(
+        '\n⚠️ WARNING: You are enabling DEBUG_LLM which may expose sensitive information like API keys.\n'
+        'This should NEVER be enabled in production.\n'
+        "Type 'y' to confirm you understand the risks: "
+    )
+    if confirmation.lower() == 'y':
+        litellm.suppress_debug_info = False
+        litellm.set_verbose = True
+    else:
+        print('DEBUG_LLM disabled due to lack of confirmation')
+        litellm.suppress_debug_info = True
+        litellm.set_verbose = False
+else:
+    litellm.suppress_debug_info = True
+    litellm.set_verbose = False
+
 if DEBUG:
     LOG_LEVEL = 'DEBUG'
 
diff --git a/pyproject.toml b/pyproject.toml
index fb538d5aa3cc..8375b7b7b47d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -101,6 +101,7 @@ reportlab = "*"
 [tool.coverage.run]
 concurrency = ["gevent"]
 
+
 [tool.poetry.group.runtime.dependencies]
 jupyterlab = "*"
 notebook = "*"
@@ -129,6 +130,7 @@ ignore = ["D1"]
 [tool.ruff.lint.pydocstyle]
 convention = "google"
 
+
 [tool.poetry.group.evaluation.dependencies]
 streamlit = "*"
 whatthepatch = "*"
diff --git a/tests/unit/test_logger_litellm.py b/tests/unit/test_logger_litellm.py
new file mode 100644
index 000000000000..916f7a058351
--- /dev/null
+++ b/tests/unit/test_logger_litellm.py
@@ -0,0 +1,55 @@
+import importlib
+import os
+import sys
+from unittest import mock
+
+import litellm
+import pytest
+
+
+@pytest.fixture
+def reset_litellm():
+    """Reset litellm settings and logger module after each test."""
+    yield
+    litellm.suppress_debug_info = False
+    litellm.set_verbose = False
+    # Remove logger module from sys.modules to force reload
+    if 'openhands.core.logger' in sys.modules:
+        del sys.modules['openhands.core.logger']
+
+
+def test_litellm_settings_debug_llm_disabled(reset_litellm):
+    """Test that litellm settings are properly configured when DEBUG_LLM is disabled."""
+    with mock.patch.dict(os.environ, {'DEBUG_LLM': 'false'}):
+        import openhands.core.logger  # noqa: F401
+
+        importlib.reload(openhands.core.logger)
+
+        assert litellm.suppress_debug_info is True
+        assert litellm.set_verbose is False
+
+
+def test_litellm_settings_debug_llm_enabled(reset_litellm):
+    """Test that litellm settings are properly configured when DEBUG_LLM is enabled and confirmed."""
+    with mock.patch.dict(os.environ, {'DEBUG_LLM': 'true'}), mock.patch(
+        'builtins.input', return_value='y'
+    ):
+        import openhands.core.logger  # noqa: F401
+
+        importlib.reload(openhands.core.logger)
+
+        assert litellm.suppress_debug_info is False
+        assert litellm.set_verbose is True
+
+
+def test_litellm_settings_debug_llm_enabled_but_declined(reset_litellm):
+    """Test that litellm settings remain disabled when DEBUG_LLM is enabled but user declines."""
+    with mock.patch.dict(os.environ, {'DEBUG_LLM': 'true'}), mock.patch(
+        'builtins.input', return_value='n'
+    ):
+        import openhands.core.logger  # noqa: F401
+
+        importlib.reload(openhands.core.logger)
+
+        assert litellm.suppress_debug_info is True
+        assert litellm.set_verbose is False

From 72af7bbba2eba2538ef2c0b4d921bff8d1de3e92 Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Thu, 16 Jan 2025 13:48:41 -0500
Subject: [PATCH 12/39] feat(eval): misc SWE-Bench improvement - use different
 resources for different instances (#6313)

Co-authored-by: openhands <openhands@all-hands.dev>
---
 evaluation/benchmarks/swe_bench/eval_infer.py | 106 +++++-----
 .../benchmarks/swe_bench/resource/mapping.py  |  38 ++++
 ...rinceton-nlp__SWE-bench_Verified-test.json |   1 +
 evaluation/benchmarks/swe_bench/run_infer.py  |  25 ++-
 .../scripts/eval/combine_final_completions.py |  69 +++++++
 .../eval/convert_oh_output_to_swe_json.py     |   3 +-
 .../scripts/eval/update_output_with_eval.py   | 191 +++++++++++-------
 .../benchmarks/swe_bench/scripts/run_infer.sh |   7 +
 8 files changed, 312 insertions(+), 128 deletions(-)
 create mode 100644 evaluation/benchmarks/swe_bench/resource/mapping.py
 create mode 100644 evaluation/benchmarks/swe_bench/resource/princeton-nlp__SWE-bench_Verified-test.json
 create mode 100644 evaluation/benchmarks/swe_bench/scripts/eval/combine_final_completions.py

diff --git a/evaluation/benchmarks/swe_bench/eval_infer.py b/evaluation/benchmarks/swe_bench/eval_infer.py
index ab7feb38b678..52972a920e8e 100644
--- a/evaluation/benchmarks/swe_bench/eval_infer.py
+++ b/evaluation/benchmarks/swe_bench/eval_infer.py
@@ -1,3 +1,4 @@
+import json
 import os
 import tempfile
 import time
@@ -11,7 +12,11 @@
 )
 from swebench.harness.test_spec import SWEbenchInstance, TestSpec, make_test_spec
 from swebench.harness.utils import load_swebench_dataset
+from tqdm import tqdm
 
+from evaluation.benchmarks.swe_bench.resource.mapping import (
+    get_instance_resource_factor,
+)
 from evaluation.benchmarks.swe_bench.run_infer import get_instance_docker_image
 from evaluation.utils.shared import (
     EvalMetadata,
@@ -81,10 +86,14 @@ def get_config(instance: pd.Series) -> AppConfig:
             base_container_image=base_container_image,
             use_host_network=False,
             # large enough timeout, since some testcases take very long to run
-            timeout=1800,
+            timeout=600,
             api_key=os.environ.get('ALLHANDS_API_KEY', None),
             remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
             remote_runtime_init_timeout=3600,
+            remote_runtime_resource_factor=get_instance_resource_factor(
+                dataset_name=metadata.dataset,
+                instance_id=instance['instance_id'],
+            ),
         ),
         # do not mount workspace
         workspace_base=None,
@@ -151,52 +160,52 @@ def process_instance(
     if runtime_failure_count > 0:
         config.sandbox.remote_runtime_resource_factor = min(
             config.sandbox.remote_runtime_resource_factor * (2**runtime_failure_count),
-            4,  # hardcode maximum resource factor to 4
+            8,
         )
         logger.warning(
-            f'This is the second attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
+            f'This is the {runtime_failure_count + 1}th attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
         )
 
-    runtime = create_runtime(config)
-    call_async_from_sync(runtime.connect)
-    # Get patch and save it to /tmp/patch.diff
-    with tempfile.TemporaryDirectory() as temp_dir:
-        # Patch file
-        patch_file_path = os.path.join(temp_dir, 'patch.diff')
-        with open(patch_file_path, 'w') as f:
-            f.write(model_patch)
-        runtime.copy_to(patch_file_path, '/tmp')
-        # Eval script
-        eval_script_path = os.path.join(temp_dir, 'eval.sh')
-        with open(eval_script_path, 'w') as f:
-            f.write(test_spec.eval_script)
-        runtime.copy_to(eval_script_path, '/tmp')
-
-    # Set +x
-    action = CmdRunAction(command='chmod +x /tmp/eval.sh')
-    action.set_hard_timeout(600)
-    logger.info(action, extra={'msg_type': 'ACTION'})
-    obs = runtime.run_action(action)
-    logger.info(obs, extra={'msg_type': 'OBSERVATION'})
-    assert obs.exit_code == 0
-
-    # Apply patch
-    exec_command = (
-        'cd /testbed && '
-        "(git apply -v /tmp/patch.diff && echo 'APPLY_PATCH_PASS' || "
-        "(echo 'Failed to apply patch with git apply, trying with patch command...' && "
-        "(patch --batch --fuzz=5 -p1 -i /tmp/patch.diff && echo 'APPLY_PATCH_PASS' || "
-        "echo 'APPLY_PATCH_FAIL')))"
-    )
-    action = CmdRunAction(command=exec_command)
-    action.set_hard_timeout(600)
-    obs = runtime.run_action(action)
-    assert isinstance(obs, CmdOutputObservation)
-    apply_patch_output = obs.content
-    assert isinstance(apply_patch_output, str)
-    instance['test_result']['apply_patch_output'] = apply_patch_output
-
     try:
+        runtime = create_runtime(config)
+        call_async_from_sync(runtime.connect)
+        # Get patch and save it to /tmp/patch.diff
+        with tempfile.TemporaryDirectory() as temp_dir:
+            # Patch file
+            patch_file_path = os.path.join(temp_dir, 'patch.diff')
+            with open(patch_file_path, 'w') as f:
+                f.write(model_patch)
+            runtime.copy_to(patch_file_path, '/tmp')
+            # Eval script
+            eval_script_path = os.path.join(temp_dir, 'eval.sh')
+            with open(eval_script_path, 'w') as f:
+                f.write(test_spec.eval_script)
+            runtime.copy_to(eval_script_path, '/tmp')
+
+        # Set +x
+        action = CmdRunAction(command='chmod +x /tmp/eval.sh')
+        action.set_hard_timeout(600)
+        logger.info(action, extra={'msg_type': 'ACTION'})
+        obs = runtime.run_action(action)
+        logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert obs.exit_code == 0
+
+        # Apply patch
+        exec_command = (
+            'cd /testbed && '
+            "(git apply -v /tmp/patch.diff && echo 'APPLY_PATCH_PASS' || "
+            "(echo 'Failed to apply patch with git apply, trying with patch command...' && "
+            "(patch --batch --fuzz=5 -p1 -i /tmp/patch.diff && echo 'APPLY_PATCH_PASS' || "
+            "echo 'APPLY_PATCH_FAIL')))"
+        )
+        action = CmdRunAction(command=exec_command)
+        action.set_hard_timeout(600)
+        obs = runtime.run_action(action)
+        assert isinstance(obs, CmdOutputObservation)
+        apply_patch_output = obs.content
+        assert isinstance(apply_patch_output, str)
+        instance['test_result']['apply_patch_output'] = apply_patch_output
+
         if 'APPLY_PATCH_FAIL' in apply_patch_output:
             logger.info(f'[{instance_id}] {APPLY_PATCH_FAIL}:\n{apply_patch_output}')
             instance['test_result']['report']['failed_apply_patch'] = True
@@ -212,7 +221,7 @@ def process_instance(
             # Run eval script in background and save output to log file
             log_file = '/tmp/eval_output.log'
             action = CmdRunAction(command=f'/tmp/eval.sh > {log_file} 2>&1 & echo $!')
-            action.set_hard_timeout(60)  # Short timeout just to get the process ID
+            action.set_hard_timeout(300)  # Short timeout just to get the process ID
             obs = runtime.run_action(action)
 
             if isinstance(obs, CmdOutputObservation) and obs.exit_code == 0:
@@ -235,7 +244,7 @@ def process_instance(
                     check_action = CmdRunAction(
                         command=f'ps -p {pid} > /dev/null; echo $?'
                     )
-                    check_action.set_hard_timeout(60)
+                    check_action.set_hard_timeout(300)
                     check_obs = runtime.run_action(check_action)
                     if (
                         isinstance(check_obs, CmdOutputObservation)
@@ -352,7 +361,14 @@ def process_instance(
 
     # Load predictions
     assert args.input_file.endswith('.jsonl'), 'Input file must be a jsonl file.'
-    predictions = pd.read_json(args.input_file, lines=True)
+    required_fields = ['instance_id', 'model_patch', 'test_result']
+    with open(args.input_file) as f:
+        predictions = pd.DataFrame.from_records(
+            [
+                {k: v for k, v in json.loads(line).items() if k in required_fields}
+                for line in tqdm(f, desc='Loading predictions')
+            ]
+        )
     assert (
         'instance_id' in predictions.columns
     ), 'Input file must contain instance_id column.'
diff --git a/evaluation/benchmarks/swe_bench/resource/mapping.py b/evaluation/benchmarks/swe_bench/resource/mapping.py
new file mode 100644
index 000000000000..ed2f433c262b
--- /dev/null
+++ b/evaluation/benchmarks/swe_bench/resource/mapping.py
@@ -0,0 +1,38 @@
+"""Mapping instance_id to resource_factor.
+
+Different instances may have different resource requirements.
+e.g., some instances may require more memory/CPU to run inference.
+This file tracks the resource requirements of different instances.
+"""
+
+import json
+import os
+from openhands.core.logger import openhands_logger as logger
+
+CUR_DIR = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_RUNTIME_RESOURCE_FACTOR = int(
+    os.environ.get('DEFAULT_RUNTIME_RESOURCE_FACTOR', 1)
+)
+
+# dataset to resource mapping
+_global_resource_mapping: dict[str, dict[str, float]] = {}
+
+
+def get_resource_mapping(dataset_name: str) -> dict[str, float]:
+    if dataset_name not in _global_resource_mapping:
+        file_path = os.path.join(CUR_DIR, f'{dataset_name}.json')
+        if not os.path.exists(file_path):
+            logger.warning(f'Resource mapping for {dataset_name} not found.')
+            return None
+
+        with open(file_path, 'r') as f:
+            _global_resource_mapping[dataset_name] = json.load(f)
+        logger.info(f'Loaded resource mapping for {dataset_name}')
+    return _global_resource_mapping[dataset_name]
+
+
+def get_instance_resource_factor(dataset_name: str, instance_id: str) -> int:
+    resource_mapping = get_resource_mapping(dataset_name)
+    if resource_mapping is None:
+        return DEFAULT_RUNTIME_RESOURCE_FACTOR
+    return int(resource_mapping.get(instance_id, DEFAULT_RUNTIME_RESOURCE_FACTOR))
diff --git a/evaluation/benchmarks/swe_bench/resource/princeton-nlp__SWE-bench_Verified-test.json b/evaluation/benchmarks/swe_bench/resource/princeton-nlp__SWE-bench_Verified-test.json
new file mode 100644
index 000000000000..161ab736da08
--- /dev/null
+++ b/evaluation/benchmarks/swe_bench/resource/princeton-nlp__SWE-bench_Verified-test.json
@@ -0,0 +1 @@
+{"pydata__xarray-6721": 8, "pytest-dev__pytest-7236": 8, "matplotlib__matplotlib-24627": 4, "django__django-15561": 4, "django__django-15098": 4, "django__django-14771": 4, "sympy__sympy-21612": 4, "sympy__sympy-15345": 4, "psf__requests-5414": 4, "astropy__astropy-14508": 2, "django__django-11451": 2, "django__django-11477": 2, "django__django-10880": 2, "django__django-11163": 2, "django__django-11815": 2, "astropy__astropy-14369": 2, "django__django-10097": 2, "django__django-10554": 2, "django__django-12304": 2, "django__django-12325": 2, "django__django-11551": 2, "django__django-11734": 2, "django__django-13109": 2, "django__django-13089": 2, "django__django-13343": 2, "django__django-13363": 2, "django__django-13809": 2, "django__django-13810": 2, "django__django-13786": 2, "django__django-13807": 2, "django__django-14493": 2, "django__django-11820": 2, "django__django-11951": 2, "django__django-11964": 2, "astropy__astropy-14309": 2, "astropy__astropy-14365": 2, "astropy__astropy-12907": 2, "astropy__astropy-14182": 2, "django__django-15161": 2, "django__django-15128": 2, "django__django-14999": 2, "django__django-14915": 2, "django__django-14752": 2, "django__django-14765": 2, "django__django-14089": 2, "django__django-15252": 2, "django__django-15380": 2, "django__django-15382": 2, "django__django-15499": 2, "django__django-15467": 2, "django__django-15280": 2, "django__django-15315": 2, "django__django-15277": 2, "django__django-15268": 2, "django__django-15629": 2, "django__django-15695": 2, "django__django-15732": 2, "django__django-15863": 2, "django__django-16082": 2, "django__django-16145": 2, "django__django-16256": 2, "django__django-16429": 2, "django__django-16454": 2, "django__django-16493": 2, "matplotlib__matplotlib-13989": 2, "matplotlib__matplotlib-20488": 2, "django__django-15503": 2, "django__django-15525": 2, "django__django-15375": 2, "django__django-15278": 2, "matplotlib__matplotlib-21568": 2, "matplotlib__matplotlib-20859": 2, "matplotlib__matplotlib-20826": 2, "matplotlib__matplotlib-20676": 2, "matplotlib__matplotlib-23412": 2, "matplotlib__matplotlib-22719": 2, "matplotlib__matplotlib-23299": 2, "matplotlib__matplotlib-22865": 2, "matplotlib__matplotlib-24149": 2, "matplotlib__matplotlib-24177": 2, "matplotlib__matplotlib-24570": 2, "matplotlib__matplotlib-24637": 2, "matplotlib__matplotlib-24970": 2, "matplotlib__matplotlib-23476": 2, "matplotlib__matplotlib-24026": 2, "matplotlib__matplotlib-23314": 2, "matplotlib__matplotlib-25332": 2, "matplotlib__matplotlib-25311": 2, "matplotlib__matplotlib-25122": 2, "matplotlib__matplotlib-25479": 2, "matplotlib__matplotlib-26342": 2, "psf__requests-2317": 2, "matplotlib__matplotlib-25960": 2, "matplotlib__matplotlib-25775": 2, "pydata__xarray-4356": 2, "pydata__xarray-4075": 2, "pydata__xarray-6461": 2, "pydata__xarray-4687": 2, "pydata__xarray-6599": 2, "pylint-dev__pylint-4661": 2, "django__django-15554": 2, "django__django-15563": 2, "pytest-dev__pytest-5262": 2, "pytest-dev__pytest-10081": 2, "scikit-learn__scikit-learn-12973": 2, "scikit-learn__scikit-learn-13124": 2, "scikit-learn__scikit-learn-13779": 2, "scikit-learn__scikit-learn-14141": 2, "scikit-learn__scikit-learn-13439": 2, "scikit-learn__scikit-learn-13496": 2, "scikit-learn__scikit-learn-15100": 2, "scikit-learn__scikit-learn-25102": 2, "scikit-learn__scikit-learn-25232": 2, "scikit-learn__scikit-learn-25747": 2, "scikit-learn__scikit-learn-26323": 2, "scikit-learn__scikit-learn-9288": 2, "scikit-learn__scikit-learn-14496": 2, "scikit-learn__scikit-learn-14629": 2, "sphinx-doc__sphinx-8265": 2, "sphinx-doc__sphinx-8548": 2, "sphinx-doc__sphinx-8593": 2, "sphinx-doc__sphinx-8595": 2, "sphinx-doc__sphinx-8621": 2, "sphinx-doc__sphinx-8638": 2, "sphinx-doc__sphinx-9229": 2, "sphinx-doc__sphinx-9281": 2, "sphinx-doc__sphinx-9461": 2, "sphinx-doc__sphinx-9591": 2, "sphinx-doc__sphinx-9658": 2, "sphinx-doc__sphinx-9673": 2, "sympy__sympy-12096": 2, "sympy__sympy-12481": 2, "sphinx-doc__sphinx-10323": 2, "sphinx-doc__sphinx-7590": 2, "sympy__sympy-13877": 2, "sympy__sympy-12489": 2, "sympy__sympy-15809": 2, "sympy__sympy-14711": 2, "sympy__sympy-16597": 2, "sympy__sympy-16766": 2, "sympy__sympy-16792": 2, "sympy__sympy-15875": 2, "sympy__sympy-17655": 2, "sympy__sympy-18189": 2, "sympy__sympy-18763": 2, "sympy__sympy-19040": 2, "sympy__sympy-19495": 2, "sympy__sympy-19637": 2, "sympy__sympy-19783": 2, "sympy__sympy-17630": 2, "sympy__sympy-20428": 2, "sympy__sympy-20590": 2, "sympy__sympy-20801": 2, "sympy__sympy-21379": 2, "sympy__sympy-21847": 2, "sympy__sympy-22456": 2, "sympy__sympy-22714": 2, "sympy__sympy-22914": 2, "sympy__sympy-23262": 2, "sympy__sympy-23413": 2, "sympy__sympy-23534": 2, "sympy__sympy-24066": 2, "sympy__sympy-24213": 2, "sympy__sympy-24443": 2, "sympy__sympy-24562": 2, "sympy__sympy-24661": 2}
diff --git a/evaluation/benchmarks/swe_bench/run_infer.py b/evaluation/benchmarks/swe_bench/run_infer.py
index ac9e85b60b10..8375295b99b7 100644
--- a/evaluation/benchmarks/swe_bench/run_infer.py
+++ b/evaluation/benchmarks/swe_bench/run_infer.py
@@ -9,6 +9,9 @@
 from datasets import load_dataset
 
 import openhands.agenthub
+from evaluation.benchmarks.swe_bench.resource.mapping import (
+    get_instance_resource_factor,
+)
 from evaluation.utils.shared import (
     EvalException,
     EvalMetadata,
@@ -41,9 +44,10 @@
 from openhands.utils.shutdown_listener import sleep_if_should_continue
 
 USE_HINT_TEXT = os.environ.get('USE_HINT_TEXT', 'false').lower() == 'true'
-USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'false').lower() == 'true'
+USE_INSTANCE_IMAGE = os.environ.get('USE_INSTANCE_IMAGE', 'true').lower() == 'true'
 RUN_WITH_BROWSING = os.environ.get('RUN_WITH_BROWSING', 'false').lower() == 'true'
 
+
 AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
     'CodeActAgent': codeact_user_response,
 }
@@ -135,6 +139,10 @@ def get_config(
             remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
             keep_runtime_alive=False,
             remote_runtime_init_timeout=3600,
+            remote_runtime_resource_factor=get_instance_resource_factor(
+                dataset_name=metadata.dataset,
+                instance_id=instance['instance_id'],
+            ),
         ),
         # do not mount workspace
         workspace_base=None,
@@ -239,7 +247,7 @@ def initialize_runtime(
         assert_and_raise(obs.exit_code == 0, f'Failed to source ~/.bashrc: {str(obs)}')
 
         action = CmdRunAction(command='source /swe_util/instance_swe_entry.sh')
-        action.set_hard_timeout(3600)
+        action.set_hard_timeout(600)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -351,7 +359,7 @@ def complete_runtime(
         action = CmdRunAction(
             command=f'git diff --no-color --cached {instance["base_commit"]}'
         )
-        action.set_hard_timeout(600 + 100 * n_retries)
+        action.timeout = max(300 + 100 * n_retries, 600)
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -399,7 +407,7 @@ def process_instance(
             8,
         )
         logger.warning(
-            f'This is the second attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
+            f'This is the {runtime_failure_count + 1}th attempt for instance {instance.instance_id}, setting resource factor to {config.sandbox.remote_runtime_resource_factor}'
         )
     runtime = create_runtime(config)
     call_async_from_sync(runtime.connect)
@@ -479,6 +487,10 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
                 subset = dataset[dataset[filter_column].isin(selected_ids)]
                 logger.info(f'Retained {subset.shape[0]} tasks after filtering')
                 return subset
+    skip_ids = os.environ.get('SKIP_IDS', '').split(',')
+    if len(skip_ids) > 0:
+        logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...')
+        return dataset[~dataset[filter_column].isin(skip_ids)]
     return dataset
 
 
@@ -501,8 +513,10 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
     # NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing
     # so we don't need to manage file uploading to OpenHands's repo
     dataset = load_dataset(args.dataset, split=args.split)
-    logger.info(f'Loaded dataset {args.dataset} with split {args.split}')
     swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
+    logger.info(
+        f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
+    )
 
     llm_config = None
     if args.llm_config:
@@ -531,6 +545,7 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
     )
 
     output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
+    print(f'### OUTPUT FILE: {output_file} ###')
     instances = prepare_dataset(swe_bench_tests, output_file, args.eval_n_limit)
 
     if len(instances) > 0 and not isinstance(
diff --git a/evaluation/benchmarks/swe_bench/scripts/eval/combine_final_completions.py b/evaluation/benchmarks/swe_bench/scripts/eval/combine_final_completions.py
new file mode 100644
index 000000000000..6fa5aeda83f7
--- /dev/null
+++ b/evaluation/benchmarks/swe_bench/scripts/eval/combine_final_completions.py
@@ -0,0 +1,69 @@
+import argparse
+import gzip
+import json
+import os
+from glob import glob
+
+from tqdm import tqdm
+
+tqdm.pandas()
+
+
+# Load trajectories for resolved instances
+def load_completions(output_dir: str, instance_id: str):
+    glob_path = os.path.join(output_dir, 'llm_completions', instance_id, '*.json')
+    files = sorted(glob(glob_path))  # this is ascending order
+    # pick the last file (last turn)
+    try:
+        file_path = files[-1]
+    except IndexError:
+        # print(f'No files found for instance {instance_id}: files={files}')
+        return None
+    with open(file_path, 'r') as f:
+        result = json.load(f)
+    # create messages
+    messages = result['messages']
+    messages.append(result['response']['choices'][0]['message'])
+    tools = result['kwargs']['tools']
+    return {
+        'messages': messages,
+        'tools': tools,
+    }
+
+
+parser = argparse.ArgumentParser()
+parser.add_argument('jsonl_path', type=str)
+args = parser.parse_args()
+
+output_dir = os.path.dirname(args.jsonl_path)
+output_path = os.path.join(output_dir, 'output.with_completions.jsonl.gz')
+
+# Check if output would be different from input
+needs_update = False
+with open(args.jsonl_path, 'r') as f_in:
+    for line in tqdm(f_in, desc='Checking for changes'):
+        data = json.loads(line)
+        new_completions = load_completions(output_dir, data['instance_id'])
+        current_completions = data.get('raw_completions')
+        if current_completions != new_completions:
+            needs_update = True
+            break
+
+if not needs_update:
+    print('No updates required. Skipping file update.')
+    exit(0)
+
+if os.path.exists(output_path):
+    print(f'Output file already exists at {output_path}, overwriting? (y/n)')
+    if input() != 'y':
+        print('Exiting...')
+        exit(0)
+
+# Process line by line
+with open(args.jsonl_path, 'r') as f_in, gzip.open(output_path, 'wt') as f_out:
+    for line in tqdm(f_in):
+        data = json.loads(line)
+        data['raw_completions'] = load_completions(output_dir, data['instance_id'])
+        f_out.write(json.dumps(data) + '\n')
+
+print(f'Saved compressed output to {output_path}')
diff --git a/evaluation/benchmarks/swe_bench/scripts/eval/convert_oh_output_to_swe_json.py b/evaluation/benchmarks/swe_bench/scripts/eval/convert_oh_output_to_swe_json.py
index f333012f489a..69000106c6c2 100644
--- a/evaluation/benchmarks/swe_bench/scripts/eval/convert_oh_output_to_swe_json.py
+++ b/evaluation/benchmarks/swe_bench/scripts/eval/convert_oh_output_to_swe_json.py
@@ -22,7 +22,8 @@ def convert_row_to_swebench_format(row):
     elif 'test_result' in row and 'git_patch' in row['test_result']:
         model_patch = row['test_result']['git_patch']
     else:
-        raise ValueError(f'Row {row} does not have a git_patch')
+        print(f'WARNING: Row {row} does not have a git_patch')
+        model_patch = ''
 
     return {
         'instance_id': row['instance_id'],
diff --git a/evaluation/benchmarks/swe_bench/scripts/eval/update_output_with_eval.py b/evaluation/benchmarks/swe_bench/scripts/eval/update_output_with_eval.py
index d9c5c540f24b..f8527acd7a6c 100644
--- a/evaluation/benchmarks/swe_bench/scripts/eval/update_output_with_eval.py
+++ b/evaluation/benchmarks/swe_bench/scripts/eval/update_output_with_eval.py
@@ -3,7 +3,7 @@
 import os
 from collections import defaultdict
 
-import pandas as pd
+from tqdm import tqdm
 
 parser = argparse.ArgumentParser()
 parser.add_argument('input_file', type=str)
@@ -11,8 +11,7 @@
 
 dirname = os.path.dirname(args.input_file)
 
-df = pd.read_json(args.input_file, lines=True)
-
+# Initialize counters and data structures
 instance_id_to_status = defaultdict(
     lambda: {
         'empty_generation': False,
@@ -23,15 +22,7 @@
     }
 )
 
-
-# Apply the status to the dataframe
-def apply_report(row):
-    instance_id = row['instance_id']
-    if instance_id in instance_id_to_status:
-        return dict(instance_id_to_status[instance_id])
-    return row.get('report', {})
-
-
+# Process official report if it exists
 swebench_official_report_json = os.path.join(dirname, 'report.json')
 openhands_remote_report_jsonl = args.input_file.replace(
     '.jsonl', '.swebench_eval.jsonl'
@@ -90,113 +81,159 @@ def apply_report(row):
             f'- [{instance_id}](./eval_outputs/{instance_id}/run_instance.log)\n'
         )
 
-    df['report'] = df.apply(apply_report, axis=1)
-
     with open(output_md_filepath, 'w') as f:
         f.write(output_md)
 
 elif os.path.exists(openhands_remote_report_jsonl):
     output_md_filepath = args.input_file.replace('.jsonl', '.swebench_eval.md')
 
-    df_eval = pd.read_json(openhands_remote_report_jsonl, lines=True, orient='records')
-
-    assert len(df['instance_id'].unique()) == len(
-        df
-    ), 'There are duplicate instance ids in the original output which is not allowed'
-    assert len(df_eval['instance_id'].unique()) == len(
-        df_eval
-    ), 'There are duplicate instance ids in the eval report which is not allowed'
-
-    for _, row in df_eval.iterrows():
-        instance_id_to_status[row['instance_id']] = row['test_result']['report']
-    df['report'] = df.apply(apply_report, axis=1)
-
-    report_is_dict = df['report'].apply(lambda x: isinstance(x, dict))
-    if not report_is_dict.all():
-        print(df[~report_is_dict])
-        raise ValueError(f'Report is not a dict, but a {type(row["report"])}')
-
-    _n_instances = len(df)
-    _n_resolved = len(df[df['report'].apply(lambda x: x.get('resolved', False))])
-    _n_unresolved = _n_instances - _n_resolved
-    _n_empty_patch = len(
-        df[df['report'].apply(lambda x: x.get('empty_generation', False))]
-    )
-    _n_error = len(df[df['report'].apply(lambda x: x.get('error_eval', False))])
+    # First pass: Read eval report and count instances
+    instance_ids = set()
+    eval_instance_ids = set()
+
+    # Count instances in original file
+    n_instances = 0
+    with open(args.input_file, 'r') as f:
+        for line in tqdm(f, desc='Counting instances in original file'):
+            data = json.loads(line)
+            instance_ids.add(data['instance_id'])
+            n_instances += 1
+    print(f'Total instances in original file: {n_instances}')
+
+    # Process eval report
+    n_eval_instances = 0
+    with open(openhands_remote_report_jsonl, 'r') as f:
+        for line in tqdm(f, desc='Processing eval report'):
+            data = json.loads(line)
+            instance_id = data['instance_id']
+            eval_instance_ids.add(instance_id)
+            n_eval_instances += 1
+            instance_id_to_status[instance_id] = data['test_result']['report']
+    print(f'Total instances in eval report: {n_eval_instances}')
+
+    # Verify no duplicates
+    assert (
+        len(instance_ids) == n_instances
+    ), 'Duplicate instance ids found in original output'
+    assert (
+        len(eval_instance_ids) == n_eval_instances
+    ), 'Duplicate instance ids found in eval report'
+
+    # Initialize counters
+    stats = {'total': len(instance_ids), 'resolved': 0, 'empty_patch': 0, 'error': 0}
+
+    # Collect instance IDs by category
+    resolved_ids = []
+    unresolved_ids = []
+    error_ids = []
+    empty_patch_ids = []
+    timeout_ids = []
+
+    # Process original file and categorize instances
+    with open(args.input_file, 'r') as f:
+        for line in f:
+            data = json.loads(line)
+            instance_id = data['instance_id']
+            report = instance_id_to_status[instance_id]
+
+            if report.get('resolved', False):
+                stats['resolved'] += 1
+                resolved_ids.append(instance_id)
+            else:
+                unresolved_ids.append(instance_id)
+
+            if report.get('empty_generation', False):
+                stats['empty_patch'] += 1
+                empty_patch_ids.append(instance_id)
+            if report.get('error_eval', False):
+                stats['error'] += 1
+                error_ids.append(instance_id)
+            if report.get('test_timeout', False):
+                timeout_ids.append(instance_id)
+
+    # Generate markdown report
+    def _instance_id_to_log_path(instance_id):
+        path = f"{args.input_file.replace('.jsonl', '.swebench_eval.logs')}/instance_{instance_id}.log"
+        return os.path.relpath(path, start=dirname)
+
+    # ... rest of markdown generation code remains the same ...
     output_md = (
         '# SWE-bench Report\n'
         'This folder contains the evaluation results of the SWE-bench using the [official evaluation docker containerization](https://github.com/princeton-nlp/SWE-bench/blob/main/docs/20240627_docker/README.md#choosing-the-right-cache_level).\n\n'
         '## Summary\n'
-        f'- submitted instances: {_n_instances}\n'
-        f'- empty patch instances: {_n_empty_patch}\n'
-        f'- resolved instances: {_n_resolved}\n'
-        f'- unresolved instances: {_n_unresolved}\n'
-        f'- error instances: {_n_error}\n'
+        f'- submitted instances: {stats["total"]}\n'
+        f'- empty patch instances: {stats["empty_patch"]}\n'
+        f'- resolved instances: {stats["resolved"]}\n'
+        f'- unresolved instances: {len(unresolved_ids)}\n'
+        f'- error instances: {stats["error"]}\n'
     )
 
-    def _instance_id_to_log_path(instance_id):
-        path = f"{args.input_file.replace('.jsonl', '.swebench_eval.logs')}/instance_{instance_id}.log"
-        # make it relative path
-        path = os.path.relpath(path, start=dirname)
-        return path
-
     output_md += '\n## Resolved Instances\n'
     # instance_id to status
-    for instance_id in sorted(
-        df[df['report'].apply(lambda x: x.get('resolved', False))][
-            'instance_id'
-        ].unique()
-    ):
+    for instance_id in resolved_ids:
         instance_id_to_status[instance_id]['resolved'] = True
         output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
 
     output_md += '\n## Unresolved Instances\n'
-    for instance_id in sorted(
-        df[~df['report'].apply(lambda x: x.get('resolved', False))][
-            'instance_id'
-        ].unique()
-    ):
+    for instance_id in unresolved_ids:
         output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
 
     output_md += '\n## Error Instances\n'
-    for instance_id in sorted(
-        df[df['report'].apply(lambda x: x.get('error_eval', False))][
-            'instance_id'
-        ].unique()
-    ):
+    for instance_id in error_ids:
         instance_id_to_status[instance_id]['error_eval'] = True
         output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
 
     output_md += '\n## Empty Patch Instances\n'
-    for instance_id in sorted(
-        df[df['report'].apply(lambda x: x.get('empty_generation', False))][
-            'instance_id'
-        ].unique()
-    ):
+    for instance_id in empty_patch_ids:
         instance_id_to_status[instance_id]['empty_generation'] = True
         output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
 
     output_md += '\n## Incomplete Instances\n'
-    for instance_id in sorted(
-        df[df['report'].apply(lambda x: x.get('test_timeout', False))][
-            'instance_id'
-        ].unique()
-    ):
+    for instance_id in timeout_ids:
         output_md += f'- [{instance_id}]({_instance_id_to_log_path(instance_id)})\n'
+
     with open(output_md_filepath, 'w') as f:
         f.write(output_md)
+
 else:
     print(
         f'No report file found: Both {swebench_official_report_json} and {openhands_remote_report_jsonl} do not exist.'
     )
     exit()
 
+# Before backup and update, check if any changes would be made
+needs_update = False
+with open(args.input_file, 'r') as infile:
+    for line in tqdm(infile, desc='Checking for changes'):
+        data = json.loads(line)
+        instance_id = data['instance_id']
+        if instance_id in instance_id_to_status:
+            current_report = data.get('report', {})
+            new_report = instance_id_to_status[instance_id]
+            if current_report != new_report:
+                needs_update = True
+                break
+
+if not needs_update:
+    print('No updates detected. Skipping file update.')
+    exit()
+
+# Backup and update the original file row by row
 if os.path.exists(args.input_file + '.bak'):
     conf = input('Existing backup file found. Do you want to overwrite it? (y/n)')
     if conf != 'y':
         exit()
     os.remove(args.input_file + '.bak')
 
-# backup the original file
 os.rename(args.input_file, args.input_file + '.bak')
-df.to_json(args.input_file, orient='records', lines=True)
+
+# Process and write file row by row
+with open(args.input_file + '.bak', 'r') as infile, open(
+    args.input_file, 'w'
+) as outfile:
+    for line in tqdm(infile, desc='Updating output file'):
+        data = json.loads(line)
+        instance_id = data['instance_id']
+        if instance_id in instance_id_to_status:
+            data['report'] = instance_id_to_status[instance_id]
+        outfile.write(json.dumps(data) + '\n')
diff --git a/evaluation/benchmarks/swe_bench/scripts/run_infer.sh b/evaluation/benchmarks/swe_bench/scripts/run_infer.sh
index b1d375152dc4..73e8bd3a3e55 100755
--- a/evaluation/benchmarks/swe_bench/scripts/run_infer.sh
+++ b/evaluation/benchmarks/swe_bench/scripts/run_infer.sh
@@ -108,7 +108,14 @@ if [ -z "$N_RUNS" ]; then
   echo "N_RUNS not specified, use default $N_RUNS"
 fi
 
+# Skip runs if the run number is in the SKIP_RUNS list
+# read from env variable SKIP_RUNS as a comma separated list of run numbers
+SKIP_RUNS=(${SKIP_RUNS//,/ })
 for i in $(seq 1 $N_RUNS); do
+  if [[ " ${SKIP_RUNS[@]} " =~ " $i " ]]; then
+    echo "Skipping run $i"
+    continue
+  fi
   current_eval_note="$EVAL_NOTE-run_$i"
   echo "EVAL_NOTE: $current_eval_note"
   run_eval $current_eval_note

From 9375e0d756769928c7c9097bba6ee5ffc960e613 Mon Sep 17 00:00:00 2001
From: Robert Brennan <accounts@rbren.io>
Date: Thu, 16 Jan 2025 14:17:17 -0500
Subject: [PATCH 13/39] fix browser async lock (#6316)

---
 openhands/runtime/browser/utils.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/openhands/runtime/browser/utils.py b/openhands/runtime/browser/utils.py
index 6f823e47d546..45e098c9f7d8 100644
--- a/openhands/runtime/browser/utils.py
+++ b/openhands/runtime/browser/utils.py
@@ -5,6 +5,7 @@
 from openhands.events.action import BrowseInteractiveAction, BrowseURLAction
 from openhands.events.observation import BrowserOutputObservation
 from openhands.runtime.browser.browser_env import BrowserEnv
+from openhands.utils.async_utils import call_sync_from_async
 
 
 async def browse(
@@ -29,7 +30,7 @@ async def browse(
 
     try:
         # obs provided by BrowserGym: see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/env.py#L396
-        obs = browser.step(action_str)
+        obs = await call_sync_from_async(browser.step, action_str)
         return BrowserOutputObservation(
             content=obs['text_content'],  # text content of the page
             url=obs.get('url', ''),  # URL of the page

From f8a3aeccd6381293b49f3d52300493606d45d8c7 Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Thu, 16 Jan 2025 14:21:46 -0500
Subject: [PATCH 14/39] fix: Restore missing translation keys (#6317)

Co-authored-by: openhands <openhands@all-hands.dev>
---
 frontend/src/i18n/translation.json | 22 ++++++++++++++++++++++
 1 file changed, 22 insertions(+)

diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index edbbabb01c8f..f0d0a87062d8 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -1259,6 +1259,28 @@
         "no": "Kunne ikke hente modeller og agenter",
         "ja": "モデルとエージェントの取得に失敗しました"
     },
+    "CONFIGURATION$SETTINGS_NOT_FOUND": {
+        "en": "Settings not found. Please check your API key",
+        "es": "Configuraciones no encontradas. Por favor revisa tu API key"
+    },
+    "CONNECT_TO_GITHUB_BY_TOKEN_MODAL$TERMS_OF_SERVICE": {
+        "en": "terms of service",
+        "es": "términos de servicio"
+    },
+    "SESSION$SERVER_CONNECTED_MESSAGE": {
+        "en": "Connected to server",
+        "zh-CN": "已连接到服务器",
+        "de": "Verbindung zum Server hergestellt",
+        "zh-TW": "已連接到伺服器",
+        "es": "Conectado al servidor",
+        "fr": "Connecté au serveur",
+        "it": "Connesso al server",
+        "pt": "Conectado ao servidor",
+        "ko-KR": "서버에 연결됨",
+        "ar": "تم الاتصال بالخادم",
+        "tr": "Sunucuya bağlandı",
+        "no": "Koblet til server"
+    },
     "SESSION$SESSION_HANDLING_ERROR_MESSAGE": {
         "en": "Error handling message",
         "zh-CN": "处理会话时出错",

From eff9e072728b2ca4f3868c07a16ebf67aa6ed00e Mon Sep 17 00:00:00 2001
From: tofarr <tofarr@gmail.com>
Date: Thu, 16 Jan 2025 13:33:36 -0700
Subject: [PATCH 15/39] Fix for issue with user id (#6320)

---
 .../conversation/file_conversation_store.py   |  9 +++-
 tests/unit/test_file_conversation_store.py    | 42 +++++++++++++++++++
 2 files changed, 50 insertions(+), 1 deletion(-)
 create mode 100644 tests/unit/test_file_conversation_store.py

diff --git a/openhands/storage/conversation/file_conversation_store.py b/openhands/storage/conversation/file_conversation_store.py
index a9682c35c82b..249374c524d3 100644
--- a/openhands/storage/conversation/file_conversation_store.py
+++ b/openhands/storage/conversation/file_conversation_store.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import json
 from dataclasses import dataclass
 from pathlib import Path
 
@@ -36,7 +37,13 @@ async def save_metadata(self, metadata: ConversationMetadata):
     async def get_metadata(self, conversation_id: str) -> ConversationMetadata:
         path = self.get_conversation_metadata_filename(conversation_id)
         json_str = await call_sync_from_async(self.file_store.read, path)
-        result = conversation_metadata_type_adapter.validate_json(json_str)
+
+        # Temp: force int to str to stop pydandic being, well... pedantic
+        json_obj = json.loads(json_str)
+        if isinstance(json_obj.get('github_user_id'), int):
+            json_obj['github_user_id'] = str(json_obj.get('github_user_id'))
+
+        result = conversation_metadata_type_adapter.validate_python(json_obj)
         return result
 
     async def delete_metadata(self, conversation_id: str) -> None:
diff --git a/tests/unit/test_file_conversation_store.py b/tests/unit/test_file_conversation_store.py
new file mode 100644
index 000000000000..323f20de7780
--- /dev/null
+++ b/tests/unit/test_file_conversation_store.py
@@ -0,0 +1,42 @@
+import json
+
+import pytest
+
+from openhands.storage.conversation.file_conversation_store import FileConversationStore
+from openhands.storage.data_models.conversation_metadata import ConversationMetadata
+from openhands.storage.memory import InMemoryFileStore
+
+
+@pytest.mark.asyncio
+async def test_load_store():
+    store = FileConversationStore(InMemoryFileStore({}))
+    expected = ConversationMetadata(
+        conversation_id='some-conversation-id',
+        github_user_id='some-user-id',
+        selected_repository='some-repo',
+        title="Let's talk about trains",
+    )
+    await store.save_metadata(expected)
+    found = await store.get_metadata('some-conversation-id')
+    assert expected == found
+
+
+@pytest.mark.asyncio
+async def test_load_int_user_id():
+    store = FileConversationStore(
+        InMemoryFileStore(
+            {
+                'sessions/some-conversation-id/metadata.json': json.dumps(
+                    {
+                        'conversation_id': 'some-conversation-id',
+                        'github_user_id': 12345,
+                        'selected_repository': 'some-repo',
+                        'title': "Let's talk about trains",
+                        'created_at': '2025-01-16T19:51:04.886331Z',
+                    }
+                )
+            }
+        )
+    )
+    found = await store.get_metadata('some-conversation-id')
+    assert found.github_user_id == '12345'

From 313c8eca206cdc885a299ebf811e2b0d5e28e907 Mon Sep 17 00:00:00 2001
From: tofarr <tofarr@gmail.com>
Date: Thu, 16 Jan 2025 15:03:38 -0700
Subject: [PATCH 16/39] Fix closing sessions (again) (#6322)

Co-authored-by: Robert Brennan <accounts@rbren.io>
---
 openhands/core/config/sandbox_config.py       |   4 +-
 openhands/runtime/builder/remote.py           |   7 +-
 .../action_execution_client.py                |   3 +-
 openhands/runtime/utils/request.py            |   3 +-
 .../server/routes/manage_conversations.py     |   2 +-
 openhands/server/session/agent_session.py     |  33 +-
 openhands/server/session/manager.py           | 389 +++++++++++-------
 openhands/server/session/session.py           |  12 +-
 openhands/utils/http_session.py               |  24 ++
 tests/unit/test_agent_controller.py           |   8 +-
 tests/unit/test_manager.py                    |  78 ++--
 11 files changed, 339 insertions(+), 224 deletions(-)
 create mode 100644 openhands/utils/http_session.py

diff --git a/openhands/core/config/sandbox_config.py b/openhands/core/config/sandbox_config.py
index 3a0b705dd02d..0ea40f29faab 100644
--- a/openhands/core/config/sandbox_config.py
+++ b/openhands/core/config/sandbox_config.py
@@ -41,7 +41,7 @@ class SandboxConfig:
 
     remote_runtime_api_url: str = 'http://localhost:8000'
     local_runtime_url: str = 'http://localhost'
-    keep_runtime_alive: bool = True
+    keep_runtime_alive: bool = False
     rm_all_containers: bool = False
     api_key: str | None = None
     base_container_image: str = 'nikolaik/python-nodejs:python3.12-nodejs22'  # default to nikolaik/python-nodejs:python3.12-nodejs22 for eventstream runtime
@@ -60,7 +60,7 @@ class SandboxConfig:
     runtime_startup_env_vars: dict[str, str] = field(default_factory=dict)
     browsergym_eval_env: str | None = None
     platform: str | None = None
-    close_delay: int = 900
+    close_delay: int = 15
     remote_runtime_resource_factor: int = 1
     enable_gpu: bool = False
     docker_runtime_kwargs: str | None = None
diff --git a/openhands/runtime/builder/remote.py b/openhands/runtime/builder/remote.py
index b2e869eca3bf..a728460a374e 100644
--- a/openhands/runtime/builder/remote.py
+++ b/openhands/runtime/builder/remote.py
@@ -9,6 +9,7 @@
 from openhands.core.logger import openhands_logger as logger
 from openhands.runtime.builder import RuntimeBuilder
 from openhands.runtime.utils.request import send_request
+from openhands.utils.http_session import HttpSession
 from openhands.utils.shutdown_listener import (
     should_continue,
     sleep_if_should_continue,
@@ -18,12 +19,10 @@
 class RemoteRuntimeBuilder(RuntimeBuilder):
     """This class interacts with the remote Runtime API for building and managing container images."""
 
-    def __init__(
-        self, api_url: str, api_key: str, session: requests.Session | None = None
-    ):
+    def __init__(self, api_url: str, api_key: str, session: HttpSession | None = None):
         self.api_url = api_url
         self.api_key = api_key
-        self.session = session or requests.Session()
+        self.session = session or HttpSession()
         self.session.headers.update({'X-API-Key': self.api_key})
 
     def build(
diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py
index f8c93dd5561f..a2455dd2b6b8 100644
--- a/openhands/runtime/impl/action_execution/action_execution_client.py
+++ b/openhands/runtime/impl/action_execution/action_execution_client.py
@@ -35,6 +35,7 @@
 from openhands.runtime.base import Runtime
 from openhands.runtime.plugins import PluginRequirement
 from openhands.runtime.utils.request import send_request
+from openhands.utils.http_session import HttpSession
 
 
 class ActionExecutionClient(Runtime):
@@ -55,7 +56,7 @@ def __init__(
         attach_to_existing: bool = False,
         headless_mode: bool = True,
     ):
-        self.session = requests.Session()
+        self.session = HttpSession()
         self.action_semaphore = threading.Semaphore(1)  # Ensure one action at a time
         self._runtime_initialized: bool = False
         self._vscode_token: str | None = None  # initial dummy value
diff --git a/openhands/runtime/utils/request.py b/openhands/runtime/utils/request.py
index e05a083e7b0d..dd0394425f0b 100644
--- a/openhands/runtime/utils/request.py
+++ b/openhands/runtime/utils/request.py
@@ -4,6 +4,7 @@
 import requests
 from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
 
+from openhands.utils.http_session import HttpSession
 from openhands.utils.tenacity_stop import stop_if_should_exit
 
 
@@ -34,7 +35,7 @@ def is_retryable_error(exception):
     wait=wait_exponential(multiplier=1, min=4, max=60),
 )
 def send_request(
-    session: requests.Session,
+    session: HttpSession,
     method: str,
     url: str,
     timeout: int = 10,
diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py
index 0015cf0e90aa..0e8a136670e2 100644
--- a/openhands/server/routes/manage_conversations.py
+++ b/openhands/server/routes/manage_conversations.py
@@ -158,7 +158,7 @@ async def search_conversations(
         for conversation in conversation_metadata_result_set.results
         if hasattr(conversation, 'created_at')
     )
-    running_conversations = await session_manager.get_agent_loop_running(
+    running_conversations = await session_manager.get_running_agent_loops(
         get_user_id(request), set(conversation_ids)
     )
     result = ConversationInfoResultSet(
diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py
index 70bf6eeca6bb..285acccbfbe4 100644
--- a/openhands/server/session/agent_session.py
+++ b/openhands/server/session/agent_session.py
@@ -1,4 +1,5 @@
 import asyncio
+import time
 from typing import Callable, Optional
 
 from openhands.controller import AgentController
@@ -16,10 +17,10 @@
 from openhands.runtime.base import Runtime
 from openhands.security import SecurityAnalyzer, options
 from openhands.storage.files import FileStore
-from openhands.utils.async_utils import call_async_from_sync, call_sync_from_async
+from openhands.utils.async_utils import call_sync_from_async
 from openhands.utils.shutdown_listener import should_continue
 
-WAIT_TIME_BEFORE_CLOSE = 300
+WAIT_TIME_BEFORE_CLOSE = 90
 WAIT_TIME_BEFORE_CLOSE_INTERVAL = 5
 
 
@@ -36,7 +37,8 @@ class AgentSession:
     controller: AgentController | None = None
     runtime: Runtime | None = None
     security_analyzer: SecurityAnalyzer | None = None
-    _initializing: bool = False
+    _starting: bool = False
+    _started_at: float = 0
     _closed: bool = False
     loop: asyncio.AbstractEventLoop | None = None
 
@@ -88,7 +90,8 @@ async def start(
         if self._closed:
             logger.warning('Session closed before starting')
             return
-        self._initializing = True
+        self._starting = True
+        self._started_at = time.time()
         self._create_security_analyzer(config.security.security_analyzer)
         await self._create_runtime(
             runtime_name=runtime_name,
@@ -109,24 +112,19 @@ async def start(
         self.event_stream.add_event(
             ChangeAgentStateAction(AgentState.INIT), EventSource.ENVIRONMENT
         )
-        self._initializing = False
+        self._starting = False
 
-    def close(self):
+    async def close(self):
         """Closes the Agent session"""
         if self._closed:
             return
         self._closed = True
-        call_async_from_sync(self._close)
-
-    async def _close(self):
-        seconds_waited = 0
-        while self._initializing and should_continue():
+        while self._starting and should_continue():
             logger.debug(
                 f'Waiting for initialization to finish before closing session {self.sid}'
             )
             await asyncio.sleep(WAIT_TIME_BEFORE_CLOSE_INTERVAL)
-            seconds_waited += WAIT_TIME_BEFORE_CLOSE_INTERVAL
-            if seconds_waited > WAIT_TIME_BEFORE_CLOSE:
+            if time.time() <= self._started_at + WAIT_TIME_BEFORE_CLOSE:
                 logger.error(
                     f'Waited too long for initialization to finish before closing session {self.sid}'
                 )
@@ -311,3 +309,12 @@ def _maybe_restore_state(self) -> State | None:
             else:
                 logger.debug('No events found, no state to restore')
         return restored_state
+
+    def get_state(self) -> AgentState | None:
+        controller = self.controller
+        if controller:
+            return controller.state.agent_state
+        if time.time() > self._started_at + WAIT_TIME_BEFORE_CLOSE:
+            # If 5 minutes have elapsed and we still don't have a controller, something has gone wrong
+            return AgentState.ERROR
+        return None
diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py
index 67358f61fbe8..f3158f81b1e6 100644
--- a/openhands/server/session/manager.py
+++ b/openhands/server/session/manager.py
@@ -2,6 +2,7 @@
 import json
 import time
 from dataclasses import dataclass, field
+from typing import Generic, Iterable, TypeVar
 from uuid import uuid4
 
 import socketio
@@ -9,26 +10,29 @@
 from openhands.core.config import AppConfig
 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.stream import EventStream, session_exists
+from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE
 from openhands.server.session.conversation import Conversation
 from openhands.server.session.session import ROOM_KEY, Session
 from openhands.server.settings import Settings
 from openhands.storage.files import FileStore
-from openhands.utils.async_utils import call_sync_from_async
+from openhands.utils.async_utils import wait_all
 from openhands.utils.shutdown_listener import should_continue
 
 _REDIS_POLL_TIMEOUT = 1.5
 _CHECK_ALIVE_INTERVAL = 15
 
 _CLEANUP_INTERVAL = 15
-_CLEANUP_EXCEPTION_WAIT_TIME = 15
+MAX_RUNNING_CONVERSATIONS = 3
+T = TypeVar('T')
 
 
 @dataclass
-class _SessionIsRunningCheck:
-    request_id: str
-    request_sids: list[str]
-    running_sids: set[str] = field(default_factory=set)
+class _ClusterQuery(Generic[T]):
+    query_id: str
+    request_ids: set[str] | None
+    result: T
     flag: asyncio.Event = field(default_factory=asyncio.Event)
 
 
@@ -38,10 +42,10 @@ class SessionManager:
     config: AppConfig
     file_store: FileStore
     _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)
+    _local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
     _last_alive_timestamps: dict[str, float] = field(default_factory=dict)
     _redis_listen_task: asyncio.Task | None = None
-    _session_is_running_checks: dict[str, _SessionIsRunningCheck] = field(
+    _running_sid_queries: dict[str, _ClusterQuery[set[str]]] = field(
         default_factory=dict
     )
     _active_conversations: dict[str, tuple[Conversation, int]] = field(
@@ -52,7 +56,7 @@ class SessionManager:
     )
     _conversations_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
     _cleanup_task: asyncio.Task | None = None
-    _has_remote_connections_flags: dict[str, asyncio.Event] = field(
+    _connection_queries: dict[str, _ClusterQuery[dict[str, str]]] = field(
         default_factory=dict
     )
 
@@ -60,7 +64,7 @@ async def __aenter__(self):
         redis_client = self._get_redis_client()
         if redis_client:
             self._redis_listen_task = asyncio.create_task(self._redis_subscribe())
-        self._cleanup_task = asyncio.create_task(self._cleanup_detached_conversations())
+        self._cleanup_task = asyncio.create_task(self._cleanup_stale())
         return self
 
     async def __aexit__(self, exc_type, exc_value, traceback):
@@ -82,7 +86,7 @@ async def _redis_subscribe(self):
         logger.debug('_redis_subscribe')
         redis_client = self._get_redis_client()
         pubsub = redis_client.pubsub()
-        await pubsub.subscribe('oh_event')
+        await pubsub.subscribe('session_msg')
         while should_continue():
             try:
                 message = await pubsub.get_message(
@@ -108,59 +112,71 @@ async def _process_message(self, message: dict):
             session = self._local_agent_loops_by_sid.get(sid)
             if session:
                 await session.dispatch(data['data'])
-        elif message_type == 'is_session_running':
+        elif message_type == 'running_agent_loops_query':
             # Another node in the cluster is asking if the current node is running the session given.
-            request_id = data['request_id']
-            sids = [
-                sid for sid in data['sids'] if sid in self._local_agent_loops_by_sid
-            ]
+            query_id = data['query_id']
+            sids = self._get_running_agent_loops_locally(
+                data.get('user_id'), data.get('filter_to_sids')
+            )
             if sids:
                 await self._get_redis_client().publish(
-                    'oh_event',
+                    'session_msg',
                     json.dumps(
                         {
-                            'request_id': request_id,
-                            'sids': sids,
-                            'message_type': 'session_is_running',
+                            'query_id': query_id,
+                            'sids': list(sids),
+                            'message_type': 'running_agent_loops_response',
                         }
                     ),
                 )
-        elif message_type == 'session_is_running':
-            request_id = data['request_id']
+        elif message_type == 'running_agent_loops_response':
+            query_id = data['query_id']
             for sid in data['sids']:
                 self._last_alive_timestamps[sid] = time.time()
-            check = self._session_is_running_checks.get(request_id)
-            if check:
-                check.running_sids.update(data['sids'])
-                if len(check.request_sids) == len(check.running_sids):
-                    check.flag.set()
-        elif message_type == 'has_remote_connections_query':
+            running_query = self._running_sid_queries.get(query_id)
+            if running_query:
+                running_query.result.update(data['sids'])
+                if running_query.request_ids is not None and len(
+                    running_query.request_ids
+                ) == len(running_query.result):
+                    running_query.flag.set()
+        elif message_type == 'connections_query':
             # Another node in the cluster is asking if the current node is connected to a session
-            sid = data['sid']
-            required = sid in self.local_connection_id_to_session_id.values()
-            if required:
+            query_id = data['query_id']
+            connections = self._get_connections_locally(
+                data.get('user_id'), data.get('filter_to_sids')
+            )
+            if connections:
                 await self._get_redis_client().publish(
-                    'oh_event',
+                    'session_msg',
                     json.dumps(
-                        {'sid': sid, 'message_type': 'has_remote_connections_response'}
+                        {
+                            'query_id': query_id,
+                            'connections': connections,
+                            'message_type': 'connections_response',
+                        }
                     ),
                 )
-        elif message_type == 'has_remote_connections_response':
-            sid = data['sid']
-            flag = self._has_remote_connections_flags.get(sid)
-            if flag:
-                flag.set()
+        elif message_type == 'connections_response':
+            query_id = data['query_id']
+            connection_query = self._connection_queries.get(query_id)
+            if connection_query:
+                connection_query.result.update(**data['connections'])
+                if connection_query.request_ids is not None and len(
+                    connection_query.request_ids
+                ) == len(connection_query.result):
+                    connection_query.flag.set()
         elif message_type == 'close_session':
             sid = data['sid']
             if sid in self._local_agent_loops_by_sid:
-                await self._on_close_session(sid)
+                await self._close_session(sid)
         elif message_type == 'session_closing':
             # Session closing event - We only get this in the event of graceful shutdown,
             # which can't be guaranteed - nodes can simply vanish unexpectedly!
             sid = data['sid']
             logger.debug(f'session_closing:{sid}')
             # Create a list of items to process to avoid modifying dict during iteration
-            items = list(self.local_connection_id_to_session_id.items())
+            items = list(self._local_connection_id_to_session_id.items())
             for connection_id, local_sid in items:
                 if sid == local_sid:
                     logger.warning(
@@ -208,7 +224,7 @@ async def join_conversation(
     ):
         logger.info(f'join_conversation:{sid}:{connection_id}')
         await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
-        self.local_connection_id_to_session_id[connection_id] = sid
+        self._local_connection_id_to_session_id[connection_id] = sid
         event_stream = await self._get_event_stream(sid)
         if not event_stream:
             return await self.maybe_start_agent_loop(sid, settings, user_id)
@@ -226,7 +242,7 @@ async def detach_from_conversation(self, conversation: Conversation):
                     self._active_conversations.pop(sid)
                     self._detached_conversations[sid] = (conversation, time.time())
 
-    async def _cleanup_detached_conversations(self):
+    async def _cleanup_stale(self):
         while should_continue():
             if self._get_redis_client():
                 # Debug info for HA envs
@@ -240,7 +256,7 @@ async def _cleanup_detached_conversations(self):
                     f'Running agent loops: {len(self._local_agent_loops_by_sid)}'
                 )
                 logger.info(
-                    f'Local connections: {len(self.local_connection_id_to_session_id)}'
+                    f'Local connections: {len(self._local_connection_id_to_session_id)}'
                 )
             try:
                 async with self._conversations_lock:
@@ -250,97 +266,180 @@ async def _cleanup_detached_conversations(self):
                         await conversation.disconnect()
                         self._detached_conversations.pop(sid, None)
 
+                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 = self._get_connections_locally(
+                    filter_to_sids=set(sid_to_close)
+                )
+                connected_sids = {sid for _, sid in connections.items()}
+                sid_to_close = [
+                    sid for sid in sid_to_close if sid not in connected_sids
+                ]
+
+                if sid_to_close:
+                    connections = await self._get_connections_remotely(
+                        filter_to_sids=set(sid_to_close)
+                    )
+                    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)
                 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
+                    ),
+                    timeout=WAIT_TIME_BEFORE_CLOSE,
+                )
                 return
-            except Exception as e:
-                logger.warning(f'error_cleaning_detached_conversations: {str(e)}')
-                await asyncio.sleep(_CLEANUP_EXCEPTION_WAIT_TIME)
-
-    async def get_agent_loop_running(self, user_id, sids: set[str]) -> set[str]:
-        running_sids = set(sid for sid in sids if sid in self._local_agent_loops_by_sid)
-        check_cluster_sids = [sid for sid in sids if sid not in running_sids]
-        running_cluster_sids = await self.get_agent_loop_running_in_cluster(
-            check_cluster_sids
-        )
-        running_sids.union(running_cluster_sids)
-        return running_sids
+            except Exception:
+                logger.warning('error_cleaning_stale', exc_info=True, stack_info=True)
+                await asyncio.sleep(_CLEANUP_INTERVAL)
 
     async def is_agent_loop_running(self, sid: str) -> bool:
-        if await self.is_agent_loop_running_locally(sid):
-            return True
-        if await self.is_agent_loop_running_in_cluster(sid):
-            return True
-        return False
-
-    async def is_agent_loop_running_locally(self, sid: str) -> bool:
-        return sid in self._local_agent_loops_by_sid
-
-    async def is_agent_loop_running_in_cluster(self, sid: str) -> bool:
-        running_sids = await self.get_agent_loop_running_in_cluster([sid])
-        return bool(running_sids)
-
-    async def get_agent_loop_running_in_cluster(self, sids: list[str]) -> set[str]:
+        sids = await self.get_running_agent_loops(filter_to_sids={sid})
+        return bool(sids)
+
+    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. 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."""
+        sids = self._get_running_agent_loops_locally(user_id, filter_to_sids)
+        remote_sids = await self._get_running_agent_loops_remotely(
+            user_id, filter_to_sids
+        )
+        return sids.union(remote_sids)
+
+    def _get_running_agent_loops_locally(
+        self, user_id: str | None = None, filter_to_sids: set[str] | None = None
+    ) -> set[str]:
+        items: Iterable[tuple[str, Session]] = self._local_agent_loops_by_sid.items()
+        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_running_agent_loops_remotely(
+        self,
+        user_id: str | None = None,
+        filter_to_sids: set[str] | None = None,
+    ) -> set[str]:
         """As the rest of the cluster if a session is running. Wait a for a short timeout for a reply"""
         redis_client = self._get_redis_client()
         if not redis_client:
             return set()
 
         flag = asyncio.Event()
-        request_id = str(uuid4())
-        check = _SessionIsRunningCheck(request_id=request_id, request_sids=sids)
-        self._session_is_running_checks[request_id] = check
+        query_id = str(uuid4())
+        query = _ClusterQuery[set[str]](
+            query_id=query_id, request_ids=filter_to_sids, result=set()
+        )
+        self._running_sid_queries[query_id] = query
         try:
-            logger.debug(f'publish:is_session_running:{sids}')
-            await redis_client.publish(
-                'oh_event',
-                json.dumps(
-                    {
-                        'request_id': request_id,
-                        'sids': sids,
-                        'message_type': 'is_session_running',
-                    }
-                ),
+            logger.debug(
+                f'publish:_get_running_agent_loops_remotely_query:{user_id}:{filter_to_sids}'
             )
+            data: dict = {
+                'query_id': query_id,
+                'message_type': 'running_agent_loops_query',
+            }
+            if user_id:
+                data['user_id'] = user_id
+            if filter_to_sids:
+                data['filter_to_sids'] = list(filter_to_sids)
+            await redis_client.publish('session_msg', json.dumps(data))
             async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
                 await flag.wait()
 
-            return check.running_sids
+            return query.result
         except TimeoutError:
             # Nobody replied in time
-            return check.running_sids
+            return query.result
         finally:
-            self._session_is_running_checks.pop(request_id, None)
+            self._running_sid_queries.pop(query_id, None)
+
+    async def get_connections(
+        self, user_id: str | None = None, filter_to_sids: set[str] | None = None
+    ) -> dict[str, str]:
+        connection_ids = self._get_connections_locally(user_id, filter_to_sids)
+        remote_connection_ids = await self._get_connections_remotely(
+            user_id, filter_to_sids
+        )
+        connection_ids.update(**remote_connection_ids)
+        return connection_ids
+
+    def _get_connections_locally(
+        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 _get_connections_remotely(
+        self, user_id: str | None = None, filter_to_sids: set[str] | None = None
+    ) -> dict[str, str]:
+        redis_client = self._get_redis_client()
+        if not redis_client:
+            return {}
 
-    async def _has_remote_connections(self, sid: str) -> bool:
-        """As the rest of the cluster if they still want this session running. Wait a for a short timeout for a reply"""
-        # Create a flag for the callback
         flag = asyncio.Event()
-        self._has_remote_connections_flags[sid] = flag
+        query_id = str(uuid4())
+        query = _ClusterQuery[dict[str, str]](
+            query_id=query_id, request_ids=filter_to_sids, result={}
+        )
+        self._connection_queries[query_id] = query
         try:
-            await self._get_redis_client().publish(
-                'oh_event',
-                json.dumps(
-                    {
-                        'sid': sid,
-                        'message_type': 'has_remote_connections_query',
-                    }
-                ),
+            logger.debug(
+                f'publish:get_connections_remotely_query:{user_id}:{filter_to_sids}'
             )
+            data: dict = {
+                'query_id': query_id,
+                'message_type': 'connections_query',
+            }
+            if user_id:
+                data['user_id'] = user_id
+            if filter_to_sids:
+                data['filter_to_sids'] = list(filter_to_sids)
+            await redis_client.publish('session_msg', json.dumps(data))
             async with asyncio.timeout(_REDIS_POLL_TIMEOUT):
                 await flag.wait()
 
-            result = flag.is_set()
-            return result
+            return query.result
         except TimeoutError:
             # Nobody replied in time
-            return False
+            return query.result
         finally:
-            self._has_remote_connections_flags.pop(sid, None)
+            self._connection_queries.pop(query_id, None)
 
     async def maybe_start_agent_loop(
         self, sid: str, settings: Settings, user_id: str | None
@@ -349,8 +448,18 @@ async def maybe_start_agent_loop(
         session: Session | None = None
         if not await self.is_agent_loop_running(sid):
             logger.info(f'start_agent_loop:{sid}')
+
+            response_ids = await self.get_running_agent_loops(user_id)
+            if len(response_ids) >= MAX_RUNNING_CONVERSATIONS:
+                logger.info('too_many_sessions_for:{user_id}')
+                await self.close_session(next(iter(response_ids)))
+
             session = Session(
-                sid=sid, file_store=self.file_store, config=self.config, sio=self.sio
+                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))
@@ -359,7 +468,6 @@ async def maybe_start_agent_loop(
         if not event_stream:
             logger.error(f'No event stream after starting agent loop: {sid}')
             raise RuntimeError(f'no_event_stream:{sid}')
-        asyncio.create_task(self._cleanup_session_later(sid))
         return event_stream
 
     async def _get_event_stream(self, sid: str) -> EventStream | None:
@@ -369,7 +477,7 @@ async def _get_event_stream(self, sid: str) -> EventStream | None:
             logger.info(f'found_local_agent_loop:{sid}')
             return session.agent_session.event_stream
 
-        if await self.is_agent_loop_running_in_cluster(sid):
+        if await self._get_running_agent_loops_remotely(filter_to_sids={sid}):
             logger.info(f'found_remote_agent_loop:{sid}')
             return EventStream(sid, self.file_store)
 
@@ -377,7 +485,7 @@ async def _get_event_stream(self, sid: str) -> EventStream | None:
 
     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)
+        sid = self._local_connection_id_to_session_id.get(connection_id)
         if not sid:
             raise RuntimeError(f'no_connected_session:{connection_id}')
 
@@ -393,11 +501,11 @@ async def send_to_event_stream(self, connection_id: str, data: dict):
             next_alive_check = last_alive_at + _CHECK_ALIVE_INTERVAL
             if (
                 next_alive_check > time.time()
-                or await self.is_agent_loop_running_in_cluster(sid)
+                or await self._get_running_agent_loops_remotely(filter_to_sids={sid})
             ):
                 # Send the event to the other pod
                 await redis_client.publish(
-                    'oh_event',
+                    'session_msg',
                     json.dumps(
                         {
                             'sid': sid,
@@ -411,75 +519,37 @@ async def send_to_event_stream(self, connection_id: str, data: dict):
         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)
+        sid = self._local_connection_id_to_session_id.pop(connection_id, None)
         logger.info(f'disconnect_from_session:{connection_id}:{sid}')
         if not sid:
             # This can occur if the init action was never run.
             logger.warning(f'disconnect_from_uninitialized_session:{connection_id}')
             return
 
-        if should_continue():
-            asyncio.create_task(self._cleanup_session_later(sid))
-        else:
-            await self._on_close_session(sid)
-
-    async def _cleanup_session_later(self, sid: str):
-        # Once there have been no connections to a session for a reasonable period, we close it
-        try:
-            await asyncio.sleep(self.config.sandbox.close_delay)
-        finally:
-            # If the sleep was cancelled, we still want to close these
-            await self._cleanup_session(sid)
-
-    async def _cleanup_session(self, sid: str) -> bool:
-        # Get local connections
-        logger.info(f'_cleanup_session:{sid}')
-        has_local_connections = next(
-            (True for v in self.local_connection_id_to_session_id.values() if v == sid),
-            False,
-        )
-        if has_local_connections:
-            return False
-
-        # If no local connections, get connections through redis
-        redis_client = self._get_redis_client()
-        if redis_client and await self._has_remote_connections(sid):
-            return False
-
-        # We alert the cluster in case they are interested
-        if redis_client:
-            await redis_client.publish(
-                'oh_event',
-                json.dumps({'sid': sid, 'message_type': 'session_closing'}),
-            )
-
-        await self._on_close_session(sid)
-        return True
-
     async def close_session(self, sid: str):
         session = self._local_agent_loops_by_sid.get(sid)
         if session:
-            await self._on_close_session(sid)
+            await self._close_session(sid)
 
         redis_client = self._get_redis_client()
         if redis_client:
             await redis_client.publish(
-                'oh_event',
+                'session_msg',
                 json.dumps({'sid': sid, 'message_type': 'close_session'}),
             )
 
-    async def _on_close_session(self, sid: str):
+    async def _close_session(self, sid: str):
         logger.info(f'_close_session:{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()
+            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}')
         for connnnection_id in connection_ids_to_remove:
-            self.local_connection_id_to_session_id.pop(connnnection_id, None)
+            self._local_connection_id_to_session_id.pop(connnnection_id, None)
 
         session = self._local_agent_loops_by_sid.pop(sid, None)
         if not session:
@@ -488,12 +558,17 @@ async def _on_close_session(self, sid: str):
 
         logger.info(f'closing_session:{session.sid}')
         # We alert the cluster in case they are interested
-        redis_client = self._get_redis_client()
-        if redis_client:
-            await redis_client.publish(
-                'oh_event',
-                json.dumps({'sid': session.sid, 'message_type': 'session_closing'}),
+        try:
+            redis_client = self._get_redis_client()
+            if redis_client:
+                await redis_client.publish(
+                    'session_msg',
+                    json.dumps({'sid': session.sid, 'message_type': 'session_closing'}),
+                )
+        except Exception:
+            logger.info(
+                'error_publishing_close_session_event', exc_info=True, stack_info=True
             )
 
-        await call_sync_from_async(session.close)
+        await session.close()
         logger.info(f'closed_session:{session.sid}')
diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py
index 8318ab773129..e77a77101b20 100644
--- a/openhands/server/session/session.py
+++ b/openhands/server/session/session.py
@@ -62,9 +62,17 @@ def __init__(
         self.loop = asyncio.get_event_loop()
         self.user_id = user_id
 
-    def close(self):
+    async def close(self):
+        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
-        self.agent_session.close()
+        await self.agent_session.close()
 
     async def initialize_agent(
         self,
diff --git a/openhands/utils/http_session.py b/openhands/utils/http_session.py
new file mode 100644
index 000000000000..4edc4e6546c3
--- /dev/null
+++ b/openhands/utils/http_session.py
@@ -0,0 +1,24 @@
+from dataclasses import dataclass, field
+
+import requests
+
+
+@dataclass
+class HttpSession:
+    """
+    request.Session is reusable after it has been closed. This behavior makes it
+    likely to leak file descriptors (Especially when combined with tenacity).
+    We wrap the session to make it unusable after being closed
+    """
+
+    session: requests.Session | None = field(default_factory=requests.Session)
+
+    def __getattr__(self, name):
+        if self.session is None:
+            raise ValueError('session_was_closed')
+        return object.__getattribute__(self.session, name)
+
+    def close(self):
+        if self.session is not None:
+            self.session.close()
+        self.session = None
diff --git a/tests/unit/test_agent_controller.py b/tests/unit/test_agent_controller.py
index 08226cd14683..07ff1310b689 100644
--- a/tests/unit/test_agent_controller.py
+++ b/tests/unit/test_agent_controller.py
@@ -19,7 +19,6 @@
 from openhands.llm import LLM
 from openhands.llm.metrics import Metrics
 from openhands.runtime.base import Runtime
-from openhands.storage import get_file_store
 from openhands.storage.memory import InMemoryFileStore
 
 
@@ -132,7 +131,7 @@ async def test_react_to_exception(mock_agent, mock_event_stream, mock_status_cal
 @pytest.mark.asyncio
 async def test_run_controller_with_fatal_error():
     config = AppConfig()
-    file_store = get_file_store(config.file_store, config.file_store_path)
+    file_store = InMemoryFileStore({})
     event_stream = EventStream(sid='test', file_store=file_store)
 
     agent = MagicMock(spec=Agent)
@@ -167,11 +166,12 @@ def on_event(event: Event):
         fake_user_response_fn=lambda _: 'repeat',
     )
     print(f'state: {state}')
-    print(f'event_stream: {list(event_stream.get_events())}')
+    events = list(event_stream.get_events())
+    print(f'event_stream: {events}')
     assert state.iteration == 4
     assert state.agent_state == AgentState.ERROR
     assert state.last_error == 'AgentStuckInLoopError: Agent got stuck in a loop'
-    assert len(list(event_stream.get_events())) == 11
+    assert len(events) == 11
 
 
 @pytest.mark.asyncio
diff --git a/tests/unit/test_manager.py b/tests/unit/test_manager.py
index f0ac68ff8361..cd2ddf6ba0a6 100644
--- a/tests/unit/test_manager.py
+++ b/tests/unit/test_manager.py
@@ -44,28 +44,28 @@ async def test_session_not_running_in_cluster():
         async with SessionManager(
             sio, AppConfig(), InMemoryFileStore()
         ) as session_manager:
-            result = await session_manager.is_agent_loop_running_in_cluster(
-                'non-existant-session'
+            result = await session_manager._get_running_agent_loops_remotely(
+                filter_to_sids={'non-existant-session'}
             )
-            assert result is False
+            assert result == set()
             assert sio.manager.redis.publish.await_count == 1
             sio.manager.redis.publish.assert_called_once_with(
-                'oh_event',
-                '{"request_id": "'
+                'session_msg',
+                '{"query_id": "'
                 + str(id)
-                + '", "sids": ["non-existant-session"], "message_type": "is_session_running"}',
+                + '", "message_type": "running_agent_loops_query", "filter_to_sids": ["non-existant-session"]}',
             )
 
 
 @pytest.mark.asyncio
-async def test_session_is_running_in_cluster():
+async def test_get_running_agent_loops_remotely():
     id = uuid4()
     sio = get_mock_sio(
         GetMessageMock(
             {
-                'request_id': str(id),
+                'query_id': str(id),
                 'sids': ['existing-session'],
-                'message_type': 'session_is_running',
+                'message_type': 'running_agent_loops_response',
             }
         )
     )
@@ -76,16 +76,16 @@ async def test_session_is_running_in_cluster():
         async with SessionManager(
             sio, AppConfig(), InMemoryFileStore()
         ) as session_manager:
-            result = await session_manager.is_agent_loop_running_in_cluster(
-                'existing-session'
+            result = await session_manager._get_running_agent_loops_remotely(
+                1, {'existing-session'}
             )
-            assert result is True
+            assert result == {'existing-session'}
             assert sio.manager.redis.publish.await_count == 1
             sio.manager.redis.publish.assert_called_once_with(
-                'oh_event',
-                '{"request_id": "'
+                'session_msg',
+                '{"query_id": "'
                 + str(id)
-                + '", "sids": ["existing-session"], "message_type": "is_session_running"}',
+                + '", "message_type": "running_agent_loops_query", "user_id": 1, "filter_to_sids": ["existing-session"]}',
             )
 
 
@@ -96,8 +96,8 @@ async def test_init_new_local_session():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    is_agent_loop_running_in_cluster_mock = AsyncMock()
-    is_agent_loop_running_in_cluster_mock.return_value = False
+    get_running_agent_loops_mock = AsyncMock()
+    get_running_agent_loops_mock.return_value = set()
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.1),
@@ -106,8 +106,8 @@ async def test_init_new_local_session():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
-            is_agent_loop_running_in_cluster_mock,
+            'openhands.server.session.manager.SessionManager.get_running_agent_loops',
+            get_running_agent_loops_mock,
         ),
     ):
         async with SessionManager(
@@ -130,8 +130,8 @@ async def test_join_local_session():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    is_agent_loop_running_in_cluster_mock = AsyncMock()
-    is_agent_loop_running_in_cluster_mock.return_value = False
+    get_running_agent_loops_mock = AsyncMock()
+    get_running_agent_loops_mock.return_value = set()
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
@@ -140,8 +140,8 @@ async def test_join_local_session():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
-            is_agent_loop_running_in_cluster_mock,
+            'openhands.server.session.manager.SessionManager.get_running_agent_loops',
+            get_running_agent_loops_mock,
         ),
     ):
         async with SessionManager(
@@ -167,8 +167,8 @@ async def test_join_cluster_session():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    is_agent_loop_running_in_cluster_mock = AsyncMock()
-    is_agent_loop_running_in_cluster_mock.return_value = True
+    get_running_agent_loops_mock = AsyncMock()
+    get_running_agent_loops_mock.return_value = {'new-session-id'}
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
@@ -177,8 +177,8 @@ async def test_join_cluster_session():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
-            is_agent_loop_running_in_cluster_mock,
+            'openhands.server.session.manager.SessionManager._get_running_agent_loops_remotely',
+            get_running_agent_loops_mock,
         ),
     ):
         async with SessionManager(
@@ -198,8 +198,8 @@ async def test_add_to_local_event_stream():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    is_agent_loop_running_in_cluster_mock = AsyncMock()
-    is_agent_loop_running_in_cluster_mock.return_value = False
+    get_running_agent_loops_mock = AsyncMock()
+    get_running_agent_loops_mock.return_value = set()
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
@@ -208,8 +208,8 @@ async def test_add_to_local_event_stream():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
-            is_agent_loop_running_in_cluster_mock,
+            'openhands.server.session.manager.SessionManager.get_running_agent_loops',
+            get_running_agent_loops_mock,
         ),
     ):
         async with SessionManager(
@@ -234,8 +234,8 @@ async def test_add_to_cluster_event_stream():
     mock_session = MagicMock()
     mock_session.return_value = session_instance
     sio = get_mock_sio()
-    is_agent_loop_running_in_cluster_mock = AsyncMock()
-    is_agent_loop_running_in_cluster_mock.return_value = True
+    get_running_agent_loops_mock = AsyncMock()
+    get_running_agent_loops_mock.return_value = {'new-session-id'}
     with (
         patch('openhands.server.session.manager.Session', mock_session),
         patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
@@ -244,8 +244,8 @@ async def test_add_to_cluster_event_stream():
             AsyncMock(),
         ),
         patch(
-            'openhands.server.session.manager.SessionManager.is_agent_loop_running_in_cluster',
-            is_agent_loop_running_in_cluster_mock,
+            'openhands.server.session.manager.SessionManager._get_running_agent_loops_remotely',
+            get_running_agent_loops_mock,
         ),
     ):
         async with SessionManager(
@@ -259,7 +259,7 @@ async def test_add_to_cluster_event_stream():
             )
     assert sio.manager.redis.publish.await_count == 1
     sio.manager.redis.publish.assert_called_once_with(
-        'oh_event',
+        'session_msg',
         '{"sid": "new-session-id", "message_type": "event", "data": {"event_type": "some_event"}}',
     )
 
@@ -277,7 +277,7 @@ async def test_cleanup_session_connections():
         async with SessionManager(
             sio, AppConfig(), InMemoryFileStore()
         ) as session_manager:
-            session_manager.local_connection_id_to_session_id.update(
+            session_manager._local_connection_id_to_session_id.update(
                 {
                     'conn1': 'session1',
                     'conn2': 'session1',
@@ -286,9 +286,9 @@ async def test_cleanup_session_connections():
                 }
             )
 
-            await session_manager._on_close_session('session1')
+            await session_manager._close_session('session1')
 
-            remaining_connections = session_manager.local_connection_id_to_session_id
+            remaining_connections = session_manager._local_connection_id_to_session_id
             assert 'conn1' not in remaining_connections
             assert 'conn2' not in remaining_connections
             assert 'conn3' in remaining_connections

From c10f18b3bd78f14e570c5db581e7f8d765884183 Mon Sep 17 00:00:00 2001
From: Robert Brennan <accounts@rbren.io>
Date: Thu, 16 Jan 2025 17:10:48 -0500
Subject: [PATCH 17/39] Better message when trying to reconnect (#6323)

---
 .../components/features/controls/agent-status-bar.tsx | 11 ++++++++++-
 frontend/src/context/ws-client-provider.tsx           |  3 +++
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/frontend/src/components/features/controls/agent-status-bar.tsx b/frontend/src/components/features/controls/agent-status-bar.tsx
index 400c12d02391..7fc7f3a2a26e 100644
--- a/frontend/src/components/features/controls/agent-status-bar.tsx
+++ b/frontend/src/components/features/controls/agent-status-bar.tsx
@@ -5,11 +5,16 @@ import toast from "react-hot-toast";
 import { RootState } from "#/store";
 import { AgentState } from "#/types/agent-state";
 import { AGENT_STATUS_MAP } from "../../agent-status-map.constant";
+import {
+  useWsClient,
+  WsClientProviderStatus,
+} from "#/context/ws-client-provider";
 
 export function AgentStatusBar() {
   const { t, i18n } = useTranslation();
   const { curAgentState } = useSelector((state: RootState) => state.agent);
   const { curStatusMessage } = useSelector((state: RootState) => state.status);
+  const { status } = useWsClient();
 
   const [statusMessage, setStatusMessage] = React.useState<string>("");
 
@@ -37,7 +42,11 @@ export function AgentStatusBar() {
   }, [curStatusMessage.id]);
 
   React.useEffect(() => {
-    setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
+    if (status === WsClientProviderStatus.DISCONNECTED) {
+      setStatusMessage("Trying to reconnect...");
+    } else {
+      setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
+    }
   }, [curAgentState]);
 
   return (
diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx
index 11b5ca95b3ba..bdfa8b8b0b10 100644
--- a/frontend/src/context/ws-client-provider.tsx
+++ b/frontend/src/context/ws-client-provider.tsx
@@ -81,6 +81,9 @@ export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
     !!val && typeof val === "object";
   const isString = (val: unknown): val is string => typeof val === "string";
   if (isObject(data) && "message" in data && isString(data.message)) {
+    if (data.message === "websocket error") {
+      return;
+    }
     let msgId: string | undefined;
     if (
       "data" in data &&

From 7c8a0162ae5ff7d1b8f59c476198d7c7a09a63f9 Mon Sep 17 00:00:00 2001
From: Amaechi-Okorie Onyedikachi Hope
 <51549388+Honyii@users.noreply.github.com>
Date: Fri, 17 Jan 2025 02:28:23 +0100
Subject: [PATCH 18/39] feat: add slack etiquettes (#6178)

---
 CODE_OF_CONDUCT.md | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 502058015a09..e033bde194d2 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -113,6 +113,20 @@ individual, or aggression toward or disparagement of classes of individuals.
 **Consequence**: A permanent ban from any sort of public interaction within the
 community.
 
+### Slack and Discord Etiquettes
+
+These Slack and Discord etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Let’s work together to build a supportive and welcoming community!
+
+- Communicate respectfully and professionally, avoiding sarcasm or harsh language, and remember that tone can be difficult to interpret in text.
+- Use threads for specific discussions to keep channels organized and easier to follow.
+- Tag others only when their input is critical or urgent, and use @here, @channel or @everyone sparingly to minimize disruptions.
+- Be patient, as open-source contributors and maintainers often have other commitments and may need time to respond.
+- Post questions or discussions in the most relevant channel (e.g., for [slack - #general](https://app.slack.com/client/T06P212QSEA/C06P5NCGSFP) for general topics, [slack - #questions](https://openhands-ai.slack.com/archives/C06U8UTKSAD) for queries/questions, [discord - #general](https://discord.com/channels/1222935860639563850/1222935861386018885)).
+- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
+- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
+- Always adhere to [our standards](https://github.com/All-Hands-AI/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
+- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if it’s too busy, but set notifications to alert you only when “LLMs” appears in messages. Also for Discord, go to the channel notifications and choose the option that best describes your need.
+
 ## Attribution
 
 This Code of Conduct is adapted from the [Contributor Covenant][homepage],

From 2edb2337c24f02151581a04d7b5b074320d3037a Mon Sep 17 00:00:00 2001
From: Aleksandr Kadykov <a+github@kadykov.com>
Date: Fri, 17 Jan 2025 13:51:53 +0000
Subject: [PATCH 19/39] Fix typo in Development.md (#6330)

---
 Development.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Development.md b/Development.md
index 2fa18cfa91c1..50ae8fe8b935 100644
--- a/Development.md
+++ b/Development.md
@@ -5,7 +5,7 @@ Otherwise, you can clone the OpenHands project directly.
 
 ## Start the Server for Development
 ### 1. Requirements
-* Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install)  [Ubuntu <= 22.04]
+* Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install)  [Ubuntu >= 22.04]
 * [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
 * [Python](https://www.python.org/downloads/) = 3.12
 * [NodeJS](https://nodejs.org/en/download/package-manager) >= 20.x

From 000055ba73ffff6a2ac63f6deced72fbd889e204 Mon Sep 17 00:00:00 2001
From: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
Date: Fri, 17 Jan 2025 09:43:03 -0500
Subject: [PATCH 20/39] Add initial user msg to /new_conversation route (#6314)

---
 openhands/server/routes/manage_conversations.py | 8 ++++++--
 openhands/server/session/agent_session.py       | 8 ++++++++
 openhands/server/session/manager.py             | 8 ++++++--
 openhands/server/session/session.py             | 6 ++----
 4 files changed, 22 insertions(+), 8 deletions(-)

diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py
index 0e8a136670e2..767c52b706b3 100644
--- a/openhands/server/routes/manage_conversations.py
+++ b/openhands/server/routes/manage_conversations.py
@@ -32,12 +32,14 @@
 class InitSessionRequest(BaseModel):
     github_token: str | None = None
     selected_repository: str | None = None
+    initial_user_msg: str | None = None
 
 
 async def _create_new_conversation(
     user_id: str | None,
     token: str | None,
     selected_repository: str | None,
+    initial_user_msg: str | None,
 ):
     logger.info('Loading settings')
     settings_store = await SettingsStoreImpl.get_instance(config, user_id)
@@ -89,7 +91,7 @@ async def _create_new_conversation(
 
     logger.info(f'Starting agent loop for conversation {conversation_id}')
     event_stream = await session_manager.maybe_start_agent_loop(
-        conversation_id, conversation_init_data, user_id
+        conversation_id, conversation_init_data, user_id, initial_user_msg
     )
     try:
         event_stream.subscribe(
@@ -114,10 +116,11 @@ async def new_conversation(request: Request, data: InitSessionRequest):
     user_id = get_user_id(request)
     github_token = getattr(request.state, 'github_token', '') or data.github_token
     selected_repository = data.selected_repository
+    initial_user_msg = data.initial_user_msg
 
     try:
         conversation_id = await _create_new_conversation(
-            user_id, github_token, selected_repository
+            user_id, github_token, selected_repository, initial_user_msg
         )
 
         return JSONResponse(
@@ -140,6 +143,7 @@ async def new_conversation(request: Request, data: InitSessionRequest):
                 'message': str(e),
                 'msg_id': 'STATUS$ERROR_LLM_AUTHENTICATION',
             },
+            status_code=400,
         )
 
 
diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py
index 285acccbfbe4..d876e45788d3 100644
--- a/openhands/server/session/agent_session.py
+++ b/openhands/server/session/agent_session.py
@@ -10,6 +10,7 @@
 from openhands.core.logger import openhands_logger as logger
 from openhands.core.schema.agent import AgentState
 from openhands.events.action import ChangeAgentStateAction
+from openhands.events.action.message import MessageAction
 from openhands.events.event import EventSource
 from openhands.events.stream import EventStream
 from openhands.microagent import BaseMicroAgent
@@ -71,6 +72,7 @@ async def start(
         agent_configs: dict[str, AgentConfig] | None = None,
         github_token: str | None = None,
         selected_repository: str | None = None,
+        initial_user_msg: str | None = None,
     ):
         """Starts the Agent session
         Parameters:
@@ -112,6 +114,12 @@ async def start(
         self.event_stream.add_event(
             ChangeAgentStateAction(AgentState.INIT), EventSource.ENVIRONMENT
         )
+
+        if initial_user_msg:
+            self.event_stream.add_event(
+                MessageAction(content=initial_user_msg), EventSource.USER
+            )
+            
         self._starting = False
 
     async def close(self):
diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py
index f3158f81b1e6..203a8dc3b2ed 100644
--- a/openhands/server/session/manager.py
+++ b/openhands/server/session/manager.py
@@ -442,7 +442,11 @@ async def _get_connections_remotely(
             self._connection_queries.pop(query_id, None)
 
     async def maybe_start_agent_loop(
-        self, sid: str, settings: Settings, user_id: str | None
+        self,
+        sid: str,
+        settings: Settings,
+        user_id: str | None,
+        initial_user_msg: str | None = None,
     ) -> EventStream:
         logger.info(f'maybe_start_agent_loop:{sid}')
         session: Session | None = None
@@ -462,7 +466,7 @@ async def maybe_start_agent_loop(
                 user_id=user_id,
             )
             self._local_agent_loops_by_sid[sid] = session
-            asyncio.create_task(session.initialize_agent(settings))
+            asyncio.create_task(session.initialize_agent(settings, initial_user_msg))
 
         event_stream = await self._get_event_stream(sid)
         if not event_stream:
diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py
index e77a77101b20..b24b29702086 100644
--- a/openhands/server/session/session.py
+++ b/openhands/server/session/session.py
@@ -74,10 +74,7 @@ async def close(self):
         self.is_alive = False
         await self.agent_session.close()
 
-    async def initialize_agent(
-        self,
-        settings: Settings,
-    ):
+    async def initialize_agent(self, settings: Settings, initial_user_msg: str | None):
         self.agent_session.event_stream.add_event(
             AgentStateChangedObservation('', AgentState.LOADING),
             EventSource.ENVIRONMENT,
@@ -122,6 +119,7 @@ async def initialize_agent(
                 agent_configs=self.config.get_agent_configs(),
                 github_token=github_token,
                 selected_repository=selected_repository,
+                initial_user_msg=initial_user_msg,
             )
         except Exception as e:
             logger.exception(f'Error creating agent_session: {e}')

From 8e9c3157293054f16928d3d56bb550699bd8e978 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 17 Jan 2025 15:19:45 +0000
Subject: [PATCH 21/39] chore(deps): bump the version-all group with 8 updates
 (#6331)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 poetry.lock    | 186 ++++++++++++++++++++++++-------------------------
 pyproject.toml |   6 +-
 2 files changed, 96 insertions(+), 96 deletions(-)

diff --git a/poetry.lock b/poetry.lock
index 06c119ee3497..562f082c479b 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -170,13 +170,13 @@ files = [
 
 [[package]]
 name = "anthropic"
-version = "0.42.0"
+version = "0.43.0"
 description = "The official Python library for the anthropic API"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "anthropic-0.42.0-py3-none-any.whl", hash = "sha256:46775f65b723c078a2ac9e9de44a46db5c6a4fabeacfd165e5ea78e6817f4eff"},
-    {file = "anthropic-0.42.0.tar.gz", hash = "sha256:bf8b0ed8c8cb2c2118038f29c58099d2f99f7847296cafdaa853910bfff4edf4"},
+    {file = "anthropic-0.43.0-py3-none-any.whl", hash = "sha256:f748a703f77b3244975e1aace3a935840dc653a4714fb6bba644f97cc76847b4"},
+    {file = "anthropic-0.43.0.tar.gz", hash = "sha256:06801f01d317a431d883230024318d48981758058bf7e079f33fb11f64b5a5c1"},
 ]
 
 [package.dependencies]
@@ -552,32 +552,32 @@ files = [
 
 [[package]]
 name = "boto3"
-version = "1.35.98"
+version = "1.36.1"
 description = "The AWS SDK for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "boto3-1.35.98-py3-none-any.whl", hash = "sha256:d0224e1499d7189b47aa7f469d96522d98df6f5702fccb20a95a436582ebcd9d"},
-    {file = "boto3-1.35.98.tar.gz", hash = "sha256:4b6274b4fe9d7113f978abea66a1f20c8a397c268c9d1b2a6c96b14a256da4a5"},
+    {file = "boto3-1.36.1-py3-none-any.whl", hash = "sha256:eb21380d73fec6645439c0d802210f72a0cdb3295b02953f246ff53f512faa8f"},
+    {file = "boto3-1.36.1.tar.gz", hash = "sha256:258ab77225a81d3cf3029c9afe9920cd9dec317689dfadec6f6f0a23130bb60a"},
 ]
 
 [package.dependencies]
-botocore = ">=1.35.98,<1.36.0"
+botocore = ">=1.36.1,<1.37.0"
 jmespath = ">=0.7.1,<2.0.0"
-s3transfer = ">=0.10.0,<0.11.0"
+s3transfer = ">=0.11.0,<0.12.0"
 
 [package.extras]
 crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
 
 [[package]]
 name = "botocore"
-version = "1.35.98"
+version = "1.36.1"
 description = "Low-level, data-driven core of boto 3."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "botocore-1.35.98-py3-none-any.whl", hash = "sha256:4f1c0b687488663a774ad3a5e81a5f94fae1bcada2364cfdc48482c4dbf794d5"},
-    {file = "botocore-1.35.98.tar.gz", hash = "sha256:d11742b3824bdeac3c89eeeaf5132351af41823bbcef8fc15e95c8250b1de09c"},
+    {file = "botocore-1.36.1-py3-none-any.whl", hash = "sha256:dec513b4eb8a847d79bbefdcdd07040ed9d44c20b0001136f0890a03d595705a"},
+    {file = "botocore-1.36.1.tar.gz", hash = "sha256:f789a6f272b5b3d8f8756495019785e33868e5e00dd9662a3ee7959ac939bb12"},
 ]
 
 [package.dependencies]
@@ -586,7 +586,7 @@ python-dateutil = ">=2.1,<3.0.0"
 urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""}
 
 [package.extras]
-crt = ["awscrt (==0.22.0)"]
+crt = ["awscrt (==0.23.4)"]
 
 [[package]]
 name = "browsergym-core"
@@ -2236,13 +2236,13 @@ tool = ["click (>=6.0.0)"]
 
 [[package]]
 name = "google-cloud-aiplatform"
-version = "1.76.0"
+version = "1.77.0"
 description = "Vertex AI API client library"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "google_cloud_aiplatform-1.76.0-py2.py3-none-any.whl", hash = "sha256:0b0348525b9528db7b69538ff6e86289ea2ce0d80f3784a42865fc994fe10dd1"},
-    {file = "google_cloud_aiplatform-1.76.0.tar.gz", hash = "sha256:910fb7fb6ef7ec73a48523872d669370755f59ac6d764dc8bf2fc91e7c0b2fca"},
+    {file = "google_cloud_aiplatform-1.77.0-py2.py3-none-any.whl", hash = "sha256:e9dd1bcb1b9a85eddd452916cd6ad1d9ce2d487772a9e45b1814aa0ac5633689"},
+    {file = "google_cloud_aiplatform-1.77.0.tar.gz", hash = "sha256:1e5b77fe6c7f276d7aae65bcf08a273122a71f6c4af1f43cf45821f603a74080"},
 ]
 
 [package.dependencies]
@@ -2266,8 +2266,8 @@ datasets = ["pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0de
 endpoint = ["requests (>=2.28.1)"]
 evaluation = ["pandas (>=1.0.0)", "tqdm (>=4.23.0)"]
 full = ["docker (>=5.0.3)", "explainable-ai-sdk (>=1.0.0)", "fastapi (>=0.71.0,<=0.114.0)", "google-cloud-bigquery", "google-cloud-bigquery-storage", "google-vizier (>=0.1.6)", "httpx (>=0.23.0,<0.25.0)", "immutabledict", "lit-nlp (==0.4.0)", "mlflow (>=1.27.0,<=2.16.0)", "numpy (>=1.15.0)", "pandas (>=1.0.0)", "pyarrow (>=10.0.1)", "pyarrow (>=14.0.0)", "pyarrow (>=3.0.0,<8.0dev)", "pyarrow (>=6.0.1)", "pyyaml (>=5.3.1,<7)", "ray[default] (>=2.4,<2.5.dev0 || >2.9.0,!=2.9.1,!=2.9.2,<2.10.dev0 || >=2.33.dev0,<=2.33.0)", "ray[default] (>=2.5,<=2.33.0)", "requests (>=2.28.1)", "setuptools (<70.0.0)", "starlette (>=0.17.1)", "tensorboard-plugin-profile (>=2.4.0,<2.18.0)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.3.0,<3.0.0dev)", "tensorflow (>=2.4.0,<3.0.0dev)", "tqdm (>=4.23.0)", "urllib3 (>=1.21.1,<1.27)", "uvicorn[standard] (>=0.16.0)", "werkzeug (>=2.0.0,<2.1.0dev)"]
-langchain = ["langchain (>=0.1.16,<0.4)", "langchain-core (<0.4)", "langchain-google-vertexai (<3)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)"]
-langchain-testing = ["absl-py", "cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "langchain (>=0.1.16,<0.4)", "langchain-core (<0.4)", "langchain-google-vertexai (<3)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.6.3,<3)", "pytest-xdist", "typing-extensions"]
+langchain = ["langchain (>=0.1.16,<0.4)", "langchain-core (<0.4)", "langchain-google-vertexai (<3)", "langgraph (>=0.2.45,<0.3)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)"]
+langchain-testing = ["absl-py", "cloudpickle (>=3.0,<4.0)", "google-cloud-trace (<2)", "langchain (>=0.1.16,<0.4)", "langchain-core (<0.4)", "langchain-google-vertexai (<3)", "langgraph (>=0.2.45,<0.3)", "openinference-instrumentation-langchain (>=0.1.19,<0.2)", "opentelemetry-exporter-gcp-trace (<2)", "opentelemetry-sdk (<2)", "pydantic (>=2.6.3,<3)", "pytest-xdist", "typing-extensions"]
 lit = ["explainable-ai-sdk (>=1.0.0)", "lit-nlp (==0.4.0)", "pandas (>=1.0.0)", "tensorflow (>=2.3.0,<3.0.0dev)"]
 metadata = ["numpy (>=1.15.0)", "pandas (>=1.0.0)"]
 pipelines = ["pyyaml (>=5.3.1,<7)"]
@@ -3707,13 +3707,13 @@ types-tqdm = "*"
 
 [[package]]
 name = "litellm"
-version = "1.58.1"
+version = "1.58.2"
 description = "Library to easily interface with LLM API providers"
 optional = false
 python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
 files = [
-    {file = "litellm-1.58.1-py3-none-any.whl", hash = "sha256:eae311273dd7b8be9b1fc92f12c6ec521f86166effd7ae2cec2982dbb9a7dc2c"},
-    {file = "litellm-1.58.1.tar.gz", hash = "sha256:c73dff605b830815088bdfcc6c42f380b5dc129a184b678646ce981305d11ac6"},
+    {file = "litellm-1.58.2-py3-none-any.whl", hash = "sha256:51b14b2f5e30d2d41a76fbf926d7d882f1fddbbfda8812358cb4bb27d0d27692"},
+    {file = "litellm-1.58.2.tar.gz", hash = "sha256:4e1b7191a86970bbacd30e5315d3b6a0f5fc75a99763c9164116de60c6ac0bf3"},
 ]
 
 [package.dependencies]
@@ -4583,12 +4583,12 @@ type = ["mypy (==1.11.2)"]
 
 [[package]]
 name = "modal"
-version = "0.72.11"
+version = "0.72.21"
 description = "Python client library for Modal"
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "modal-0.72.11-py3-none-any.whl", hash = "sha256:97428dfbc2cb2677eb0a17e6189fedf991296245d25e9700c2b9f8978853a2ae"},
+    {file = "modal-0.72.21-py3-none-any.whl", hash = "sha256:16ad3b1f9e5e8a7d2b6aa9746f8e3315c44f8901e5e873679ceacbdd956a7ba1"},
 ]
 
 [package.dependencies]
@@ -6239,53 +6239,53 @@ files = [
 
 [[package]]
 name = "pyarrow"
-version = "18.1.0"
+version = "19.0.0"
 description = "Python library for Apache Arrow"
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "pyarrow-18.1.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e21488d5cfd3d8b500b3238a6c4b075efabc18f0f6d80b29239737ebd69caa6c"},
-    {file = "pyarrow-18.1.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:b516dad76f258a702f7ca0250885fc93d1fa5ac13ad51258e39d402bd9e2e1e4"},
-    {file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f443122c8e31f4c9199cb23dca29ab9427cef990f283f80fe15b8e124bcc49b"},
-    {file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0a03da7f2758645d17b7b4f83c8bffeae5bbb7f974523fe901f36288d2eab71"},
-    {file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ba17845efe3aa358ec266cf9cc2800fa73038211fb27968bfa88acd09261a470"},
-    {file = "pyarrow-18.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3c35813c11a059056a22a3bef520461310f2f7eea5c8a11ef9de7062a23f8d56"},
-    {file = "pyarrow-18.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9736ba3c85129d72aefa21b4f3bd715bc4190fe4426715abfff90481e7d00812"},
-    {file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:eaeabf638408de2772ce3d7793b2668d4bb93807deed1725413b70e3156a7854"},
-    {file = "pyarrow-18.1.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:3b2e2239339c538f3464308fd345113f886ad031ef8266c6f004d49769bb074c"},
-    {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f39a2e0ed32a0970e4e46c262753417a60c43a3246972cfc2d3eb85aedd01b21"},
-    {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e31e9417ba9c42627574bdbfeada7217ad8a4cbbe45b9d6bdd4b62abbca4c6f6"},
-    {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:01c034b576ce0eef554f7c3d8c341714954be9b3f5d5bc7117006b85fcf302fe"},
-    {file = "pyarrow-18.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f266a2c0fc31995a06ebd30bcfdb7f615d7278035ec5b1cd71c48d56daaf30b0"},
-    {file = "pyarrow-18.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:d4f13eee18433f99adefaeb7e01d83b59f73360c231d4782d9ddfaf1c3fbde0a"},
-    {file = "pyarrow-18.1.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9f3a76670b263dc41d0ae877f09124ab96ce10e4e48f3e3e4257273cee61ad0d"},
-    {file = "pyarrow-18.1.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:da31fbca07c435be88a0c321402c4e31a2ba61593ec7473630769de8346b54ee"},
-    {file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:543ad8459bc438efc46d29a759e1079436290bd583141384c6f7a1068ed6f992"},
-    {file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0743e503c55be0fdb5c08e7d44853da27f19dc854531c0570f9f394ec9671d54"},
-    {file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d4b3d2a34780645bed6414e22dda55a92e0fcd1b8a637fba86800ad737057e33"},
-    {file = "pyarrow-18.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c52f81aa6f6575058d8e2c782bf79d4f9fdc89887f16825ec3a66607a5dd8e30"},
-    {file = "pyarrow-18.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:0ad4892617e1a6c7a551cfc827e072a633eaff758fa09f21c4ee548c30bcaf99"},
-    {file = "pyarrow-18.1.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:84e314d22231357d473eabec709d0ba285fa706a72377f9cc8e1cb3c8013813b"},
-    {file = "pyarrow-18.1.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:f591704ac05dfd0477bb8f8e0bd4b5dc52c1cadf50503858dce3a15db6e46ff2"},
-    {file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acb7564204d3c40babf93a05624fc6a8ec1ab1def295c363afc40b0c9e66c191"},
-    {file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74de649d1d2ccb778f7c3afff6085bd5092aed4c23df9feeb45dd6b16f3811aa"},
-    {file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f96bd502cb11abb08efea6dab09c003305161cb6c9eafd432e35e76e7fa9b90c"},
-    {file = "pyarrow-18.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ac22d7782554754a3b50201b607d553a8d71b78cdf03b33c1125be4b52397c"},
-    {file = "pyarrow-18.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:25dbacab8c5952df0ca6ca0af28f50d45bd31c1ff6fcf79e2d120b4a65ee7181"},
-    {file = "pyarrow-18.1.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a276190309aba7bc9d5bd2933230458b3521a4317acfefe69a354f2fe59f2bc"},
-    {file = "pyarrow-18.1.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ad514dbfcffe30124ce655d72771ae070f30bf850b48bc4d9d3b25993ee0e386"},
-    {file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aebc13a11ed3032d8dd6e7171eb6e86d40d67a5639d96c35142bd568b9299324"},
-    {file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6cf5c05f3cee251d80e98726b5c7cc9f21bab9e9783673bac58e6dfab57ecc8"},
-    {file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:11b676cd410cf162d3f6a70b43fb9e1e40affbc542a1e9ed3681895f2962d3d9"},
-    {file = "pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b76130d835261b38f14fc41fdfb39ad8d672afb84c447126b84d5472244cfaba"},
-    {file = "pyarrow-18.1.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:0b331e477e40f07238adc7ba7469c36b908f07c89b95dd4bd3a0ec84a3d1e21e"},
-    {file = "pyarrow-18.1.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:2c4dd0c9010a25ba03e198fe743b1cc03cd33c08190afff371749c52ccbbaf76"},
-    {file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f97b31b4c4e21ff58c6f330235ff893cc81e23da081b1a4b1c982075e0ed4e9"},
-    {file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a4813cb8ecf1809871fd2d64a8eff740a1bd3691bbe55f01a3cf6c5ec869754"},
-    {file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:05a5636ec3eb5cc2a36c6edb534a38ef57b2ab127292a716d00eabb887835f1e"},
-    {file = "pyarrow-18.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:73eeed32e724ea3568bb06161cad5fa7751e45bc2228e33dcb10c614044165c7"},
-    {file = "pyarrow-18.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:a1880dd6772b685e803011a6b43a230c23b566859a6e0c9a276c1e0faf4f4052"},
-    {file = "pyarrow-18.1.0.tar.gz", hash = "sha256:9386d3ca9c145b5539a1cfc75df07757dff870168c959b473a0bccbc3abc8c73"},
+    {file = "pyarrow-19.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:c318eda14f6627966997a7d8c374a87d084a94e4e38e9abbe97395c215830e0c"},
+    {file = "pyarrow-19.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:62ef8360ff256e960f57ce0299090fb86423afed5e46f18f1225f960e05aae3d"},
+    {file = "pyarrow-19.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2795064647add0f16563e57e3d294dbfc067b723f0fd82ecd80af56dad15f503"},
+    {file = "pyarrow-19.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a218670b26fb1bc74796458d97bcab072765f9b524f95b2fccad70158feb8b17"},
+    {file = "pyarrow-19.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:66732e39eaa2247996a6b04c8aa33e3503d351831424cdf8d2e9a0582ac54b34"},
+    {file = "pyarrow-19.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e675a3ad4732b92d72e4d24009707e923cab76b0d088e5054914f11a797ebe44"},
+    {file = "pyarrow-19.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:f094742275586cdd6b1a03655ccff3b24b2610c3af76f810356c4c71d24a2a6c"},
+    {file = "pyarrow-19.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:8e3a839bf36ec03b4315dc924d36dcde5444a50066f1c10f8290293c0427b46a"},
+    {file = "pyarrow-19.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:ce42275097512d9e4e4a39aade58ef2b3798a93aa3026566b7892177c266f735"},
+    {file = "pyarrow-19.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9348a0137568c45601b031a8d118275069435f151cbb77e6a08a27e8125f59d4"},
+    {file = "pyarrow-19.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a0144a712d990d60f7f42b7a31f0acaccf4c1e43e957f7b1ad58150d6f639c1"},
+    {file = "pyarrow-19.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2a1a109dfda558eb011e5f6385837daffd920d54ca00669f7a11132d0b1e6042"},
+    {file = "pyarrow-19.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:be686bf625aa7b9bada18defb3a3ea3981c1099697239788ff111d87f04cd263"},
+    {file = "pyarrow-19.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:239ca66d9a05844bdf5af128861af525e14df3c9591bcc05bac25918e650d3a2"},
+    {file = "pyarrow-19.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:a7bbe7109ab6198688b7079cbad5a8c22de4d47c4880d8e4847520a83b0d1b68"},
+    {file = "pyarrow-19.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:4624c89d6f777c580e8732c27bb8e77fd1433b89707f17c04af7635dd9638351"},
+    {file = "pyarrow-19.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b6d3ce4288793350dc2d08d1e184fd70631ea22a4ff9ea5c4ff182130249d9b"},
+    {file = "pyarrow-19.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:450a7d27e840e4d9a384b5c77199d489b401529e75a3b7a3799d4cd7957f2f9c"},
+    {file = "pyarrow-19.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a08e2a8a039a3f72afb67a6668180f09fddaa38fe0d21f13212b4aba4b5d2451"},
+    {file = "pyarrow-19.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:f43f5aef2a13d4d56adadae5720d1fed4c1356c993eda8b59dace4b5983843c1"},
+    {file = "pyarrow-19.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:2f672f5364b2d7829ef7c94be199bb88bf5661dd485e21d2d37de12ccb78a136"},
+    {file = "pyarrow-19.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:cf3bf0ce511b833f7bc5f5bb3127ba731e97222023a444b7359f3a22e2a3b463"},
+    {file = "pyarrow-19.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:4d8b0c0de0a73df1f1bf439af1b60f273d719d70648e898bc077547649bb8352"},
+    {file = "pyarrow-19.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92aff08e23d281c69835e4a47b80569242a504095ef6a6223c1f6bb8883431d"},
+    {file = "pyarrow-19.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3b78eff5968a1889a0f3bc81ca57e1e19b75f664d9c61a42a604bf9d8402aae"},
+    {file = "pyarrow-19.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:b34d3bde38eba66190b215bae441646330f8e9da05c29e4b5dd3e41bde701098"},
+    {file = "pyarrow-19.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5418d4d0fab3a0ed497bad21d17a7973aad336d66ad4932a3f5f7480d4ca0c04"},
+    {file = "pyarrow-19.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e82c3d5e44e969c217827b780ed8faf7ac4c53f934ae9238872e749fa531f7c9"},
+    {file = "pyarrow-19.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:f208c3b58a6df3b239e0bb130e13bc7487ed14f39a9ff357b6415e3f6339b560"},
+    {file = "pyarrow-19.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:c751c1c93955b7a84c06794df46f1cec93e18610dcd5ab7d08e89a81df70a849"},
+    {file = "pyarrow-19.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b903afaa5df66d50fc38672ad095806443b05f202c792694f3a604ead7c6ea6e"},
+    {file = "pyarrow-19.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a22a4bc0937856263df8b94f2f2781b33dd7f876f787ed746608e06902d691a5"},
+    {file = "pyarrow-19.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:5e8a28b918e2e878c918f6d89137386c06fe577cd08d73a6be8dafb317dc2d73"},
+    {file = "pyarrow-19.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:29cd86c8001a94f768f79440bf83fee23963af5e7bc68ce3a7e5f120e17edf89"},
+    {file = "pyarrow-19.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:c0423393e4a07ff6fea08feb44153302dd261d0551cc3b538ea7a5dc853af43a"},
+    {file = "pyarrow-19.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:718947fb6d82409013a74b176bf93e0f49ef952d8a2ecd068fecd192a97885b7"},
+    {file = "pyarrow-19.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c1c162c4660e0978411a4761f91113dde8da3433683efa473501254563dcbe8"},
+    {file = "pyarrow-19.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c73268cf557e688efb60f1ccbc7376f7e18cd8e2acae9e663e98b194c40c1a2d"},
+    {file = "pyarrow-19.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:edfe6d3916e915ada9acc4e48f6dafca7efdbad2e6283db6fd9385a1b23055f1"},
+    {file = "pyarrow-19.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:da410b70a7ab8eb524112f037a7a35da7128b33d484f7671a264a4c224ac131d"},
+    {file = "pyarrow-19.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:597360ffc71fc8cceea1aec1fb60cb510571a744fffc87db33d551d5de919bec"},
+    {file = "pyarrow-19.0.0.tar.gz", hash = "sha256:8d47c691765cf497aaeed4954d226568563f1b3b74ff61139f2d77876717084b"},
 ]
 
 [package.extras]
@@ -7627,40 +7627,40 @@ pyasn1 = ">=0.1.3"
 
 [[package]]
 name = "ruff"
-version = "0.9.1"
+version = "0.9.2"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743"},
-    {file = "ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f"},
-    {file = "ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb"},
-    {file = "ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca"},
-    {file = "ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce"},
-    {file = "ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969"},
-    {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd"},
-    {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a"},
-    {file = "ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b"},
-    {file = "ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831"},
-    {file = "ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab"},
-    {file = "ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1"},
-    {file = "ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366"},
-    {file = "ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f"},
-    {file = "ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72"},
-    {file = "ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19"},
-    {file = "ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7"},
-    {file = "ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17"},
+    {file = "ruff-0.9.2-py3-none-linux_armv6l.whl", hash = "sha256:80605a039ba1454d002b32139e4970becf84b5fee3a3c3bf1c2af6f61a784347"},
+    {file = "ruff-0.9.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9aab82bb20afd5f596527045c01e6ae25a718ff1784cb92947bff1f83068b00"},
+    {file = "ruff-0.9.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbd337bac1cfa96be615f6efcd4bc4d077edbc127ef30e2b8ba2a27e18c054d4"},
+    {file = "ruff-0.9.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82b35259b0cbf8daa22a498018e300b9bb0174c2bbb7bcba593935158a78054d"},
+    {file = "ruff-0.9.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b6a9701d1e371bf41dca22015c3f89769da7576884d2add7317ec1ec8cb9c3c"},
+    {file = "ruff-0.9.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cc53e68b3c5ae41e8faf83a3b89f4a5d7b2cb666dff4b366bb86ed2a85b481f"},
+    {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8efd9da7a1ee314b910da155ca7e8953094a7c10d0c0a39bfde3fcfd2a015684"},
+    {file = "ruff-0.9.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3292c5a22ea9a5f9a185e2d131dc7f98f8534a32fb6d2ee7b9944569239c648d"},
+    {file = "ruff-0.9.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a605fdcf6e8b2d39f9436d343d1f0ff70c365a1e681546de0104bef81ce88df"},
+    {file = "ruff-0.9.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c547f7f256aa366834829a08375c297fa63386cbe5f1459efaf174086b564247"},
+    {file = "ruff-0.9.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d18bba3d3353ed916e882521bc3e0af403949dbada344c20c16ea78f47af965e"},
+    {file = "ruff-0.9.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b338edc4610142355ccf6b87bd356729b62bf1bc152a2fad5b0c7dc04af77bfe"},
+    {file = "ruff-0.9.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:492a5e44ad9b22a0ea98cf72e40305cbdaf27fac0d927f8bc9e1df316dcc96eb"},
+    {file = "ruff-0.9.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:af1e9e9fe7b1f767264d26b1075ac4ad831c7db976911fa362d09b2d0356426a"},
+    {file = "ruff-0.9.2-py3-none-win32.whl", hash = "sha256:71cbe22e178c5da20e1514e1e01029c73dc09288a8028a5d3446e6bba87a5145"},
+    {file = "ruff-0.9.2-py3-none-win_amd64.whl", hash = "sha256:c5e1d6abc798419cf46eed03f54f2e0c3adb1ad4b801119dedf23fcaf69b55b5"},
+    {file = "ruff-0.9.2-py3-none-win_arm64.whl", hash = "sha256:a1b63fa24149918f8b37cef2ee6fff81f24f0d74b6f0bdc37bc3e1f2143e41c6"},
+    {file = "ruff-0.9.2.tar.gz", hash = "sha256:b5eceb334d55fae5f316f783437392642ae18e16dcf4f1858d55d3c2a0f8f5d0"},
 ]
 
 [[package]]
 name = "runloop-api-client"
-version = "0.12.0"
+version = "0.13.0"
 description = "The official Python library for the runloop API"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "runloop_api_client-0.12.0-py3-none-any.whl", hash = "sha256:284b5982b63d70ddb43b7621e121c1be80979a03d60c4d36aebfd46f02a993aa"},
-    {file = "runloop_api_client-0.12.0.tar.gz", hash = "sha256:b6cc334fbf1b775732636ec1cb7d00a0128e2632d4c08240c7e2fbf93824627f"},
+    {file = "runloop_api_client-0.13.0-py3-none-any.whl", hash = "sha256:2c356d4ecd1f1f76c7c79d7125e5c370d92889ee7e3b834cfc15e2665cb35572"},
+    {file = "runloop_api_client-0.13.0.tar.gz", hash = "sha256:268a2534a0fc774267e0ae45eb5e11a4b853d82ea6b4f89a1420f9b0b9c8cf18"},
 ]
 
 [package.dependencies]
@@ -7673,20 +7673,20 @@ typing-extensions = ">=4.10,<5"
 
 [[package]]
 name = "s3transfer"
-version = "0.10.4"
+version = "0.11.1"
 description = "An Amazon S3 Transfer Manager"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e"},
-    {file = "s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7"},
+    {file = "s3transfer-0.11.1-py3-none-any.whl", hash = "sha256:8fa0aa48177be1f3425176dfe1ab85dcd3d962df603c3dbfc585e6bf857ef0ff"},
+    {file = "s3transfer-0.11.1.tar.gz", hash = "sha256:3f25c900a367c8b7f7d8f9c34edc87e300bde424f779dc9f0a8ae4f9df9264f6"},
 ]
 
 [package.dependencies]
-botocore = ">=1.33.2,<2.0a.0"
+botocore = ">=1.36.0,<2.0a.0"
 
 [package.extras]
-crt = ["botocore[crt] (>=1.33.2,<2.0a.0)"]
+crt = ["botocore[crt] (>=1.36.0,<2.0a.0)"]
 
 [[package]]
 name = "safetensors"
@@ -9857,4 +9857,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.12"
-content-hash = "e52dad7dccfc86a34dcba7fc68255dc4cdc62ba23cb0d6dce5740917bc5dcd78"
+content-hash = "5358d6cd39f8e254c098d3ffcbb2f57213611ad132bf7e2193005f92e45b255c"
diff --git a/pyproject.toml b/pyproject.toml
index 8375b7b7b47d..ca201f2d4f0c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,7 +37,7 @@ python-multipart = "*"
 boto3 = "*"
 minio = "^7.2.8"
 gevent = "^24.2.1"
-pyarrow = "18.1.0" # transitive dependency, pinned here to avoid conflicts
+pyarrow = "19.0.0" # transitive dependency, pinned here to avoid conflicts
 tenacity = ">=8.5,<10.0"
 zope-interface = "7.2"
 pathspec = "^0.12.1"
@@ -61,7 +61,7 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
 opentelemetry-api = "1.25.0"
 opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
 modal = ">=0.66.26,<0.73.0"
-runloop-api-client = "0.12.0"
+runloop-api-client = "0.13.0"
 libtmux = ">=0.37,<0.40"
 pygithub = "^2.5.0"
 joblib = "*"
@@ -82,7 +82,7 @@ voyageai = "*"
 llama-index-embeddings-voyageai = "*"
 
 [tool.poetry.group.dev.dependencies]
-ruff = "0.9.1"
+ruff = "0.9.2"
 mypy = "1.14.1"
 pre-commit = "4.0.1"
 build = "*"

From 85a760e561d900f1fc345bca07a7a29a9a0613e5 Mon Sep 17 00:00:00 2001
From: Engel Nyst <enyst@users.noreply.github.com>
Date: Fri, 17 Jan 2025 18:38:02 +0100
Subject: [PATCH 22/39] Simplify draft llm (#6281)

Co-authored-by: openhands <openhands@all-hands.dev>
---
 docs/modules/usage/llms/custom-llm-configs.md |  30 +++++
 openhands/core/config/llm_config.py           |  15 +--
 openhands/core/config/utils.py                |  21 +--
 openhands/runtime/base.py                     |   4 +-
 openhands/runtime/utils/edit.py               |  20 ++-
 openhands/server/session/agent_session.py     |   6 +-
 tests/unit/test_agent_session.py              |   1 -
 tests/unit/test_llm_draft_config.py           | 127 ++++++++++--------
 8 files changed, 120 insertions(+), 104 deletions(-)

diff --git a/docs/modules/usage/llms/custom-llm-configs.md b/docs/modules/usage/llms/custom-llm-configs.md
index afe645bdbaa3..8a79640d468c 100644
--- a/docs/modules/usage/llms/custom-llm-configs.md
+++ b/docs/modules/usage/llms/custom-llm-configs.md
@@ -101,6 +101,36 @@ In this example:
 - Code generation uses GPT-4 with a higher token limit for generating larger code blocks
 - The default configuration remains available for other tasks
 
+# Custom Configurations with Reserved Names
+
+OpenHands can use custom LLM configurations named with reserved names, for specific use cases. If you specify the model and other settings under the reserved names, then OpenHands will load and them for a specific purpose. As of now, one such configuration is implemented: draft editor.
+
+## Draft Editor Configuration
+
+The `draft_editor` configuration is a group of settings you can provide, to specify the model to use for preliminary drafting of code edits, for any tasks that involve editing and refining code. You need to provide it under the section `[llm.draft_editor]`.
+
+For example, you can define in `config.toml` a draft editor like this:
+
+```toml
+[llm.draft_editor]
+model = "gpt-4"
+temperature = 0.2
+top_p = 0.95
+presence_penalty = 0.0
+frequency_penalty = 0.0
+```
+
+This configuration:
+- Uses GPT-4 for high-quality edits and suggestions
+- Sets a low temperature (0.2) to maintain consistency while allowing some flexibility
+- Uses a high top_p value (0.95) to consider a wide range of token options
+- Disables presence and frequency penalties to maintain focus on the specific edits needed
+
+Use this configuration when you want to let an LLM draft edits before making them. In general, it may be useful to:
+- Review and suggest code improvements
+- Refine existing content while maintaining its core meaning
+- Make precise, focused changes to code or text
+
 :::note
 Custom LLM configurations are only available when using OpenHands in development mode, via `main.py` or `cli.py`. When running via `docker run`, please use the standard configuration options.
 :::
diff --git a/openhands/core/config/llm_config.py b/openhands/core/config/llm_config.py
index bae58373811d..2687d0206940 100644
--- a/openhands/core/config/llm_config.py
+++ b/openhands/core/config/llm_config.py
@@ -1,6 +1,5 @@
 import os
 from dataclasses import dataclass, fields
-from typing import Optional
 
 from openhands.core.config.config_utils import get_field_info
 from openhands.core.logger import LOG_DIR
@@ -44,7 +43,6 @@ class LLMConfig:
         caching_prompt: Use the prompt caching feature if provided by the LLM and supported by the provider.
         log_completions: Whether to log LLM completions to the state.
         log_completions_folder: The folder to log LLM completions to. Required if log_completions is True.
-        draft_editor: A more efficient LLM to use for file editing. Introduced in [PR 3985](https://github.com/All-Hands-AI/OpenHands/pull/3985).
         custom_tokenizer: A custom tokenizer to use for token counting.
         native_tool_calling: Whether to use native tool calling if supported by the model. Can be True, False, or not set.
     """
@@ -84,7 +82,6 @@ class LLMConfig:
     caching_prompt: bool = True
     log_completions: bool = False
     log_completions_folder: str = os.path.join(LOG_DIR, 'completions')
-    draft_editor: Optional['LLMConfig'] = None
     custom_tokenizer: str | None = None
     native_tool_calling: bool | None = None
 
@@ -137,8 +134,7 @@ def to_safe_dict(self):
     def from_dict(cls, llm_config_dict: dict) -> 'LLMConfig':
         """Create an LLMConfig object from a dictionary.
 
-        This function is used to create an LLMConfig object from a dictionary,
-        with the exception of the 'draft_editor' key, which is a nested LLMConfig object.
+        This function is used to create an LLMConfig object from a dictionary.
         """
         # Keep None values to preserve defaults, filter out other dicts
         args = {
@@ -146,13 +142,4 @@ def from_dict(cls, llm_config_dict: dict) -> 'LLMConfig':
             for k, v in llm_config_dict.items()
             if not isinstance(v, dict) or v is None
         }
-        if (
-            'draft_editor' in llm_config_dict
-            and llm_config_dict['draft_editor'] is not None
-        ):
-            if isinstance(llm_config_dict['draft_editor'], LLMConfig):
-                args['draft_editor'] = llm_config_dict['draft_editor']
-            else:
-                draft_editor_config = LLMConfig(**llm_config_dict['draft_editor'])
-                args['draft_editor'] = draft_editor_config
         return cls(**args)
diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py
index 82fbfe1e2601..ccdd9494a84d 100644
--- a/openhands/core/config/utils.py
+++ b/openhands/core/config/utils.py
@@ -144,11 +144,11 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
                     logger.openhands_logger.debug(
                         'Attempt to load default LLM config from config toml'
                     )
-                    # TODO clean up draft_editor
-                    # Extract generic LLM fields, keeping draft_editor
+
+                    # Extract generic LLM fields, which are not nested LLM configs
                     generic_llm_fields = {}
                     for k, v in value.items():
-                        if not isinstance(v, dict) or k == 'draft_editor':
+                        if not isinstance(v, dict):
                             generic_llm_fields[k] = v
                     generic_llm_config = LLMConfig.from_dict(generic_llm_fields)
                     cfg.set_llm_config(generic_llm_config, 'llm')
@@ -168,22 +168,11 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
                             # results in num_retries APPLIED to claude-3-5-sonnet
                             custom_fields = {}
                             for k, v in nested_value.items():
-                                if not isinstance(v, dict) or k == 'draft_editor':
+                                if not isinstance(v, dict):
                                     custom_fields[k] = v
                             merged_llm_dict = generic_llm_config.__dict__.copy()
                             merged_llm_dict.update(custom_fields)
-                            # TODO clean up draft_editor
-                            # Handle draft_editor with fallback values:
-                            # - If draft_editor is "null", use None
-                            # - If draft_editor is in custom fields, use that value
-                            # - If draft_editor is not specified, fall back to generic config value
-                            if 'draft_editor' in custom_fields:
-                                if custom_fields['draft_editor'] == 'null':
-                                    merged_llm_dict['draft_editor'] = None
-                            else:
-                                merged_llm_dict['draft_editor'] = (
-                                    generic_llm_config.draft_editor
-                                )
+
                             custom_llm_config = LLMConfig.from_dict(merged_llm_dict)
                             cfg.set_llm_config(custom_llm_config, nested_key)
                 elif key is not None and key.lower() == 'security':
diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py
index 94a059f06aa3..0505203cf628 100644
--- a/openhands/runtime/base.py
+++ b/openhands/runtime/base.py
@@ -121,7 +121,9 @@ def __init__(
         )
 
         # Load mixins
-        FileEditRuntimeMixin.__init__(self)
+        FileEditRuntimeMixin.__init__(
+            self, enable_llm_editor=config.get_agent_config().codeact_enable_llm_editor
+        )
 
     def setup_initial_env(self) -> None:
         if self.attach_to_existing:
diff --git a/openhands/runtime/utils/edit.py b/openhands/runtime/utils/edit.py
index 43034ca2f69d..3dce0544f0a7 100644
--- a/openhands/runtime/utils/edit.py
+++ b/openhands/runtime/utils/edit.py
@@ -1,4 +1,3 @@
-import copy
 import os
 import re
 import tempfile
@@ -104,26 +103,25 @@ class FileEditRuntimeMixin(FileEditRuntimeInterface):
     # This restricts the number of lines we can edit to avoid exceeding the token limit.
     MAX_LINES_TO_EDIT = 300
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, enable_llm_editor: bool, *args, **kwargs):
         super().__init__(*args, **kwargs)
+        self.enable_llm_editor = enable_llm_editor
 
-        llm_config = self.config.get_llm_config()
+        if not self.enable_llm_editor:
+            return
 
-        if llm_config.draft_editor is None:
-            llm_config.draft_editor = copy.deepcopy(llm_config)
+        draft_editor_config = self.config.get_llm_config('draft_editor')
 
         # manually set the model name for the draft editor LLM to distinguish token costs
-        llm_metrics = Metrics(
-            model_name='draft_editor:' + llm_config.draft_editor.model
-        )
-        if llm_config.draft_editor.caching_prompt:
+        llm_metrics = Metrics(model_name='draft_editor:' + draft_editor_config.model)
+        if draft_editor_config.caching_prompt:
             logger.debug(
                 'It is not recommended to cache draft editor LLM prompts as it may incur high costs for the same prompt. '
                 'Automatically setting caching_prompt=false.'
             )
-            llm_config.draft_editor.caching_prompt = False
+            draft_editor_config.caching_prompt = False
 
-        self.draft_editor_llm = LLM(llm_config.draft_editor, metrics=llm_metrics)
+        self.draft_editor_llm = LLM(draft_editor_config, metrics=llm_metrics)
         logger.debug(
             f'[Draft edit functionality] enabled with LLM: {self.draft_editor_llm}'
         )
diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py
index d876e45788d3..5cab31e97fb6 100644
--- a/openhands/server/session/agent_session.py
+++ b/openhands/server/session/agent_session.py
@@ -271,11 +271,7 @@ def _create_controller(
             f'LLM: {agent.llm.config.model}\n'
             f'Base URL: {agent.llm.config.base_url}\n'
         )
-        if agent.llm.config.draft_editor:
-            msg += (
-                f'Draft editor LLM (for file editing): {agent.llm.config.draft_editor.model}\n'
-                f'Draft editor LLM (for file editing) Base URL: {agent.llm.config.draft_editor.base_url}\n'
-            )
+
         msg += (
             f'Agent: {agent.name}\n'
             f'Runtime: {self.runtime.__class__.__name__}\n'
diff --git a/tests/unit/test_agent_session.py b/tests/unit/test_agent_session.py
index 90fca71e394a..fdbe3d0a0257 100644
--- a/tests/unit/test_agent_session.py
+++ b/tests/unit/test_agent_session.py
@@ -26,7 +26,6 @@ def mock_agent():
     # Configure the LLM config
     llm_config.model = 'test-model'
     llm_config.base_url = 'http://test'
-    llm_config.draft_editor = None
     llm_config.max_message_chars = 1000
 
     # Set up the chain of mocks
diff --git a/tests/unit/test_llm_draft_config.py b/tests/unit/test_llm_draft_config.py
index 160bbf594899..a9803782b2de 100644
--- a/tests/unit/test_llm_draft_config.py
+++ b/tests/unit/test_llm_draft_config.py
@@ -7,7 +7,11 @@
 
 
 @pytest.fixture
-def draft_llm_toml(tmp_path: pathlib.Path) -> str:
+def config_toml_without_draft_editor(tmp_path: pathlib.Path) -> str:
+    """
+    This fixture provides a TOML config that DOES NOT contain [llm.draft_editor].
+    We'll use it to verify that the draft_editor LLM is not present in the config.
+    """
     toml_content = """
 [core]
 workspace_base = "./workspace"
@@ -15,78 +19,89 @@ def draft_llm_toml(tmp_path: pathlib.Path) -> str:
 [llm]
 model = "base-model"
 api_key = "base-api-key"
-draft_editor = { model = "draft-model", api_key = "draft-api-key" }
 
 [llm.custom1]
 model = "custom-model-1"
 api_key = "custom-api-key-1"
-# Should use draft_editor from [llm] as fallback
+    """
+    toml_file = tmp_path / 'no_draft_editor.toml'
+    toml_file.write_text(toml_content)
+    return str(toml_file)
+
+
+@pytest.fixture
+def config_toml_with_draft_editor(tmp_path: pathlib.Path) -> str:
+    """
+    This fixture provides a TOML config that DOES contain [llm.draft_editor].
+    We'll use it to verify that the draft_editor LLM is loaded as any other custom LLM.
+    """
+    toml_content = """
+[core]
+workspace_base = "./workspace"
+
+[llm]
+model = "base-model"
+api_key = "base-api-key"
+num_retries = 7
+
+[llm.draft_editor]
+model = "draft-model"
+api_key = "draft-api-key"
 
 [llm.custom2]
 model = "custom-model-2"
 api_key = "custom-api-key-2"
-draft_editor = { model = "custom-draft", api_key = "custom-draft-key" }
-
-[llm.custom3]
-model = "custom-model-3"
-api_key = "custom-api-key-3"
-draft_editor = "null"  # Explicitly set to null in TOML
     """
-    toml_file = tmp_path / 'llm_config.toml'
+    toml_file = tmp_path / 'yes_draft_editor.toml'
     toml_file.write_text(toml_content)
     return str(toml_file)
 
 
-def test_draft_editor_fallback(draft_llm_toml):
-    """Test that draft_editor is correctly handled in different scenarios:
-    - Falls back to generic [llm] section value
-    - Uses custom value when specified
-    - Can be explicitly set to null
+def test_no_draft_editor_in_config(config_toml_without_draft_editor):
+    """
+    Test that draft_editor is simply not present if not declared in the TOML.
+    Previously, we tested fallback behavior. Now, it's simplified to not exist at all.
+    This docstring remains to illustrate that the old fallback logic is removed.
     """
     config = AppConfig()
 
-    # Verify default draft_editor is None
-    default_llm = config.get_llm_config('llm')
-    assert default_llm.draft_editor is None
-
     # Load config from TOML
-    load_from_toml(config, draft_llm_toml)
-
-    # Verify generic LLM draft_editor
-    generic_llm = config.get_llm_config('llm')
-    assert generic_llm.draft_editor is not None
-    assert generic_llm.draft_editor.model == 'draft-model'
-    assert generic_llm.draft_editor.api_key == 'draft-api-key'
-
-    # Verify custom1 uses draft_editor from generic as fallback
-    custom1 = config.get_llm_config('custom1')
-    assert custom1.model == 'custom-model-1'
-    assert custom1.draft_editor is not None
-    assert custom1.draft_editor.model == 'draft-model'
-    assert custom1.draft_editor.api_key == 'draft-api-key'
-
-    # Verify custom2 overrides draft_editor
-    custom2 = config.get_llm_config('custom2')
-    assert custom2.model == 'custom-model-2'
-    assert custom2.draft_editor is not None
-    assert custom2.draft_editor.model == 'custom-draft'
-    assert custom2.draft_editor.api_key == 'custom-draft-key'
-
-    # Verify custom3 has draft_editor explicitly set to None
-    custom3 = config.get_llm_config('custom3')
-    assert custom3.model == 'custom-model-3'
-    assert custom3.draft_editor is None
-
-
-def test_draft_editor_defaults(draft_llm_toml):
-    """Test that draft_editor uses default values from LLMConfig when not specified"""
+    load_from_toml(config, config_toml_without_draft_editor)
+
+    # draft_editor should not appear in config.llms
+    assert 'draft_editor' not in config.llms
+
+
+def test_draft_editor_as_named_llm(config_toml_with_draft_editor):
+    """
+    Test that draft_editor is loaded if declared in the TOML under [llm.draft_editor].
+    This docstring references the simpler approach: if it exists, it's just another named LLM.
+    """
     config = AppConfig()
-    load_from_toml(config, draft_llm_toml)
+    load_from_toml(config, config_toml_with_draft_editor)
+
+    # draft_editor should appear as a normal named LLM
+    assert 'draft_editor' in config.llms
+
+    draft_llm = config.get_llm_config('draft_editor')
+    assert draft_llm is not None
+    assert draft_llm.model == 'draft-model'
+    assert draft_llm.api_key == 'draft-api-key'
 
-    generic_llm = config.get_llm_config('llm')
-    assert generic_llm.draft_editor.num_retries == 8  # Default from LLMConfig
-    assert generic_llm.draft_editor.embedding_model == 'local'  # Default from LLMConfig
 
-    custom2 = config.get_llm_config('custom2')
-    assert custom2.draft_editor.num_retries == 8  # Default from LLMConfig
-    assert custom2.draft_editor.embedding_model == 'local'  # Default from LLMConfig
+def test_draft_editor_fallback(config_toml_with_draft_editor):
+    """
+    Test that the draft_editor config does pick up fallbacks
+    normally set in LLMConfig class and from generic LLM.
+
+    We expect the 'draft_editor' LLM to behave just like any custom LLM would.
+    """
+    config = AppConfig()
+    load_from_toml(config, config_toml_with_draft_editor)
+
+    # Check that the normal default fields come from LLMConfig where not overridden
+    draft_editor_config = config.get_llm_config('draft_editor')
+    # num_retries is an example default from llm section
+    assert draft_editor_config.num_retries == 7
+    # embedding_model is defaulted in the LLMConfig class
+    assert draft_editor_config.embedding_model == 'local'

From c5d7caf01fcc703fe3de54013455748c3a46f071 Mon Sep 17 00:00:00 2001
From: manna_and_poem <52203545+mannaandpoem@users.noreply.github.com>
Date: Sat, 18 Jan 2025 01:50:40 +0800
Subject: [PATCH 23/39] remove useless axtree str (#6315)

---
 openhands/events/observation/browse.py | 1 -
 1 file changed, 1 deletion(-)

diff --git a/openhands/events/observation/browse.py b/openhands/events/observation/browse.py
index 1052aaf17a91..2ab73f047d8a 100644
--- a/openhands/events/observation/browse.py
+++ b/openhands/events/observation/browse.py
@@ -100,5 +100,4 @@ def get_axtree_str(self, filter_visible_only: bool = False) -> str:
             skip_generic=False,
             filter_visible_only=filter_visible_only,
         )
-        self._axtree_str = cur_axtree_txt
         return cur_axtree_txt

From 899c1f8360bafafe3d575664e714223f68a401b3 Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Fri, 17 Jan 2025 14:31:23 -0500
Subject: [PATCH 24/39] fix(bash): also show timeout reminder when
 no_change_timeout is triggered (#6318)

Co-authored-by: Robert Brennan <accounts@rbren.io>
---
 evaluation/benchmarks/swe_bench/run_infer.py  |   2 +-
 .../tests/t07_interactive_commands.py         |  73 +++++++++++
 evaluation/utils/shared.py                    |  33 +++--
 .../codeact_agent/function_calling.py         |  13 +-
 openhands/events/action/commands.py           |   8 +-
 openhands/runtime/base.py                     |   5 +-
 .../action_execution_client.py                |   6 +
 .../runtime/impl/remote/remote_runtime.py     |  40 ++++--
 openhands/runtime/utils/bash.py               | 116 +++++++++++-------
 tests/runtime/test_bash.py                    |  87 ++++++++++---
 tests/unit/test_action_serialization.py       |   2 +
 tests/unit/test_bash_session.py               |  50 ++++----
 tests/unit/test_security.py                   |   1 +
 13 files changed, 322 insertions(+), 114 deletions(-)
 create mode 100644 evaluation/integration_tests/tests/t07_interactive_commands.py

diff --git a/evaluation/benchmarks/swe_bench/run_infer.py b/evaluation/benchmarks/swe_bench/run_infer.py
index 8375295b99b7..511a22c4ed9a 100644
--- a/evaluation/benchmarks/swe_bench/run_infer.py
+++ b/evaluation/benchmarks/swe_bench/run_infer.py
@@ -359,7 +359,7 @@ def complete_runtime(
         action = CmdRunAction(
             command=f'git diff --no-color --cached {instance["base_commit"]}'
         )
-        action.timeout = max(300 + 100 * n_retries, 600)
+        action.set_hard_timeout(max(300 + 100 * n_retries, 600))
         logger.info(action, extra={'msg_type': 'ACTION'})
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
diff --git a/evaluation/integration_tests/tests/t07_interactive_commands.py b/evaluation/integration_tests/tests/t07_interactive_commands.py
new file mode 100644
index 000000000000..24a66d3f38c2
--- /dev/null
+++ b/evaluation/integration_tests/tests/t07_interactive_commands.py
@@ -0,0 +1,73 @@
+import hashlib
+
+from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
+from openhands.events.action import (
+    AgentFinishAction,
+    FileWriteAction,
+    MessageAction,
+)
+from openhands.events.event import Event
+from openhands.events.observation import AgentDelegateObservation
+from openhands.runtime.base import Runtime
+
+
+class Test(BaseIntegrationTest):
+    INSTRUCTION = 'Execute the python script /workspace/python_script.py with input "John" and "25" and tell me the secret number.'
+    SECRET_NUMBER = int(hashlib.sha256(str(25).encode()).hexdigest()[:8], 16) % 1000
+
+    @classmethod
+    def initialize_runtime(cls, runtime: Runtime) -> None:
+        from openhands.core.logger import openhands_logger as logger
+
+        action = FileWriteAction(
+            path='/workspace/python_script.py',
+            content=(
+                'name = input("Enter your name: "); age = input("Enter your age: "); '
+                'import hashlib; secret = int(hashlib.sha256(str(age).encode()).hexdigest()[:8], 16) % 1000; '
+                'print(f"Hello {name}, you are {age} years old. Tell you a secret number: {secret}")'
+            ),
+        )
+        logger.info(action, extra={'msg_type': 'ACTION'})
+        observation = runtime.run_action(action)
+        logger.info(observation, extra={'msg_type': 'OBSERVATION'})
+
+    @classmethod
+    def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
+        from openhands.core.logger import openhands_logger as logger
+
+        # check if the license information is in any message
+        message_actions = [
+            event
+            for event in histories
+            if isinstance(
+                event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
+            )
+        ]
+        logger.info(f'Total message-like events: {len(message_actions)}')
+
+        for event in message_actions:
+            try:
+                if isinstance(event, AgentDelegateObservation):
+                    content = event.content
+                elif isinstance(event, AgentFinishAction):
+                    content = event.outputs.get('content', '')
+                    if event.thought:
+                        content += f'\n\n{event.thought}'
+                elif isinstance(event, MessageAction):
+                    content = event.content
+                else:
+                    logger.warning(f'Unexpected event type: {type(event)}')
+                    continue
+
+                if str(cls.SECRET_NUMBER) in content:
+                    return TestResult(success=True)
+            except Exception as e:
+                logger.error(f'Error processing event: {e}')
+
+        logger.debug(
+            f'Total messages: {len(message_actions)}. Messages: {message_actions}'
+        )
+        return TestResult(
+            success=False,
+            reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
+        )
diff --git a/evaluation/utils/shared.py b/evaluation/utils/shared.py
index 23c5b319d3c2..5b3ce8c8bd4d 100644
--- a/evaluation/utils/shared.py
+++ b/evaluation/utils/shared.py
@@ -371,7 +371,6 @@ def _process_instance_wrapper(
             error = str(e)
             stacktrace = traceback.format_exc()
             if attempt == max_retries:
-                logger.exception(e)
                 msg = (
                     '-' * 10
                     + '\n'
@@ -395,19 +394,13 @@ def _process_instance_wrapper(
                 + '-' * 10
                 + '\n'
             )
-            if isinstance(
-                e,
-                (
-                    AgentRuntimeDisconnectedError,
-                    AgentRuntimeUnavailableError,
-                    AgentRuntimeNotFoundError,
-                ),
-            ):
+            # e is likely an EvalException, so we can't directly infer it from type
+            # but rather check if it's a fatal error
+            if is_fatal_runtime_error(str(e)):
                 runtime_failure_count += 1
                 msg += f'Runtime disconnected error detected for instance {instance.instance_id}, runtime failure count: {runtime_failure_count}'
+                msg += '\n' + '-' * 10 + '\n'
             logger.error(msg)
-            if use_mp:
-                print(msg)  # use print to directly print to console
             time.sleep(5)
 
 
@@ -564,6 +557,7 @@ def is_fatal_evaluation_error(error: str | None) -> bool:
         AgentRuntimeNotReadyError,
         AgentRuntimeDisconnectedError,
         AgentRuntimeNotFoundError,
+        ConnectionError,
     ]
 
     if any(exception.__name__ in error for exception in FATAL_EXCEPTIONS):
@@ -573,6 +567,23 @@ def is_fatal_evaluation_error(error: str | None) -> bool:
     return False
 
 
+def is_fatal_runtime_error(error: str | None) -> bool:
+    if not error:
+        return False
+
+    FATAL_RUNTIME_ERRORS = [
+        AgentRuntimeUnavailableError,
+        AgentRuntimeDisconnectedError,
+        AgentRuntimeNotFoundError,
+    ]
+
+    if any(exception.__name__ in error for exception in FATAL_RUNTIME_ERRORS):
+        logger.error(f'Fatal runtime error detected: {error}')
+        return True
+
+    return False
+
+
 def get_metrics(state: State) -> dict[str, Any]:
     """Extract metrics from the state."""
     metrics = state.metrics.get() if state.metrics else {}
diff --git a/openhands/agenthub/codeact_agent/function_calling.py b/openhands/agenthub/codeact_agent/function_calling.py
index d06cf01cd3e4..af07393dd4c3 100644
--- a/openhands/agenthub/codeact_agent/function_calling.py
+++ b/openhands/agenthub/codeact_agent/function_calling.py
@@ -31,7 +31,7 @@
 
 _BASH_DESCRIPTION = """Execute a bash command in the terminal.
 * Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
-* Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command like `C-c` (Ctrl+C) to interrupt the process.
+* Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, the assistant can interact with the running process and send empty `command` to retrieve any additional logs, or send additional text (set `command` to the text) to STDIN of the running process, or send command like `C-c` (Ctrl+C), `C-d` (Ctrl+D), `C-z` (Ctrl+Z) to interrupt the process.
 """
 
 CmdRunTool = ChatCompletionToolParam(
@@ -46,6 +46,11 @@
                     'type': 'string',
                     'description': 'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process.',
                 },
+                'is_input': {
+                    'type': 'string',
+                    'description': 'If True, the command is an input to the running process. If False, the command is a bash command to be executed in the terminal. Default is False.',
+                    'enum': ['true', 'false'],
+                },
             },
             'required': ['command'],
         },
@@ -488,6 +493,12 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
                     f'Failed to parse tool call arguments: {tool_call.function.arguments}'
                 ) from e
             if tool_call.function.name == 'execute_bash':
+                # this is an LLM error: add empty command to avoid breaking the tool call
+                if 'command' not in arguments:
+                    arguments['command'] = ''
+                # convert is_input to boolean
+                if 'is_input' in arguments:
+                    arguments['is_input'] = arguments['is_input'] == 'true'
                 action = CmdRunAction(**arguments)
             elif tool_call.function.name == 'execute_ipython_cell':
                 action = IPythonRunCellAction(**arguments)
diff --git a/openhands/events/action/commands.py b/openhands/events/action/commands.py
index 67b586f86ad9..ab5c77de290a 100644
--- a/openhands/events/action/commands.py
+++ b/openhands/events/action/commands.py
@@ -11,8 +11,10 @@
 
 @dataclass
 class CmdRunAction(Action):
-    command: str
-    # When `command` is empty, it will be used to print the current tmux window
+    command: (
+        str  # When `command` is empty, it will be used to print the current tmux window
+    )
+    is_input: bool = False  # if True, the command is an input to the running process
     thought: str = ''
     blocking: bool = False
     # If blocking is True, the command will be run in a blocking manner.
@@ -28,7 +30,7 @@ def message(self) -> str:
         return f'Running command: {self.command}'
 
     def __str__(self) -> str:
-        ret = f'**CmdRunAction (source={self.source})**\n'
+        ret = f'**CmdRunAction (source={self.source}, is_input={self.is_input})**\n'
         if self.thought:
             ret += f'THOUGHT: {self.thought}\n'
         ret += f'COMMAND:\n{self.command}'
diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py
index 0505203cf628..4e5729ac7789 100644
--- a/openhands/runtime/base.py
+++ b/openhands/runtime/base.py
@@ -197,9 +197,10 @@ async def _handle_action(self, event: Action) -> None:
                 e, AgentRuntimeDisconnectedError
             ):
                 err_id = 'STATUS$ERROR_RUNTIME_DISCONNECTED'
-            self.log('error', f'Unexpected error while running action: {str(e)}')
+            error_message = f'{type(e).__name__}: {str(e)}'
+            self.log('error', f'Unexpected error while running action: {error_message}')
             self.log('error', f'Problematic action: {str(event)}')
-            self.send_error_message(err_id, str(e))
+            self.send_error_message(err_id, error_message)
             self.close()
             return
 
diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py
index a2455dd2b6b8..38afc544870c 100644
--- a/openhands/runtime/impl/action_execution/action_execution_client.py
+++ b/openhands/runtime/impl/action_execution/action_execution_client.py
@@ -59,6 +59,7 @@ def __init__(
         self.session = HttpSession()
         self.action_semaphore = threading.Semaphore(1)  # Ensure one action at a time
         self._runtime_initialized: bool = False
+        self._runtime_closed: bool = False
         self._vscode_token: str | None = None  # initial dummy value
         super().__init__(
             config,
@@ -283,4 +284,9 @@ def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
         return self.send_action_for_execution(action)
 
     def close(self) -> None:
+        # Make sure we don't close the session multiple times
+        # Can happen in evaluation
+        if self._runtime_closed:
+            return
+        self._runtime_closed = True
         self.session.close()
diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py
index 2842a7b50ef6..41e1620e295f 100644
--- a/openhands/runtime/impl/remote/remote_runtime.py
+++ b/openhands/runtime/impl/remote/remote_runtime.py
@@ -13,6 +13,7 @@
     AgentRuntimeNotReadyError,
     AgentRuntimeUnavailableError,
 )
+from openhands.core.logger import openhands_logger as logger
 from openhands.events import EventStream
 from openhands.runtime.builder.remote import RemoteRuntimeBuilder
 from openhands.runtime.impl.action_execution.action_execution_client import (
@@ -75,6 +76,10 @@ def __init__(
         self.available_hosts: dict[str, int] = {}
         self._runtime_initialized: bool = False
 
+    def log(self, level: str, message: str) -> None:
+        message = f'[runtime session_id={self.sid} runtime_id={self.runtime_id or "unknown"}] {message}'
+        getattr(logger, level)(message, stacklevel=2)
+
     def _get_action_execution_server_host(self):
         return self.runtime_url
 
@@ -350,20 +355,33 @@ def close(self):
             super().close()
             return
         try:
-            with self._send_runtime_api_request(
-                'POST',
-                f'{self.config.sandbox.remote_runtime_api_url}/stop',
-                json={'runtime_id': self.runtime_id},
-            ):
-                self.log('debug', 'Runtime stopped.')
+            if not self._runtime_closed:
+                with self._send_runtime_api_request(
+                    'POST',
+                    f'{self.config.sandbox.remote_runtime_api_url}/stop',
+                    json={'runtime_id': self.runtime_id},
+                ):
+                    self.log('debug', 'Runtime stopped.')
         except Exception as e:
             raise e
         finally:
             super().close()
 
     def _send_runtime_api_request(self, method, url, **kwargs):
-        return send_request(self.session, method, url, **kwargs)
+        try:
+            return send_request(self.session, method, url, **kwargs)
+        except requests.Timeout:
+            self.log(
+                'error',
+                f'No response received within the timeout period for url: {url}',
+            )
+            raise
 
+    @tenacity.retry(
+        retry=tenacity.retry_if_exception_type(ConnectionError),
+        stop=tenacity.stop_after_attempt(3) | stop_if_should_exit(),
+        wait=tenacity.wait_exponential(multiplier=1, min=4, max=60),
+    )
     def _send_action_server_request(self, method, url, **kwargs):
         try:
             return super()._send_action_server_request(method, url, **kwargs)
@@ -375,14 +393,14 @@ def _send_action_server_request(self, method, url, **kwargs):
             raise
 
         except requests.HTTPError as e:
-            if e.response.status_code in (404, 502):
+            if e.response.status_code in (404, 502, 504):
                 if e.response.status_code == 404:
                     raise AgentRuntimeDisconnectedError(
-                        'Runtime is not responding. This may be temporary, please try again.'
+                        f'Runtime is not responding. This may be temporary, please try again. Original error: {e}'
                     ) from e
-                else:  # 502
+                else:  # 502, 504
                     raise AgentRuntimeDisconnectedError(
-                        'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again.'
+                        f'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again. Original error: {e}'
                     ) from e
             elif e.response.status_code == 503:
                 self.log('warning', 'Runtime appears to be paused. Resuming...')
diff --git a/openhands/runtime/utils/bash.py b/openhands/runtime/utils/bash.py
index 87b2ae405f1d..9da848c44dcc 100644
--- a/openhands/runtime/utils/bash.py
+++ b/openhands/runtime/utils/bash.py
@@ -461,22 +461,28 @@ def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservati
         # Strip the command of any leading/trailing whitespace
         logger.debug(f'RECEIVED ACTION: {action}')
         command = action.command.strip()
-        is_special_key = self._is_special_key(command)
-
-        # Handle when prev command is hard timeout
+        is_input: bool = action.is_input
 
-        if command == '' and self.prev_status not in {
+        # If the previous command is not completed, we need to check if the command is empty
+        if self.prev_status not in {
             BashCommandStatus.CONTINUE,
             BashCommandStatus.NO_CHANGE_TIMEOUT,
             BashCommandStatus.HARD_TIMEOUT,
         }:
-            return CmdOutputObservation(
-                content='ERROR: No previous command to continue from. '
-                + 'Previous command has to be timeout to be continued.',
-                command='',
-                metadata=CmdOutputMetadata(),
-            )
+            if command == '':
+                return CmdOutputObservation(
+                    content='ERROR: No previous running command to retrieve logs from.',
+                    command='',
+                    metadata=CmdOutputMetadata(),
+                )
+            if is_input:
+                return CmdOutputObservation(
+                    content='ERROR: No previous running command to interact with.',
+                    command='',
+                    metadata=CmdOutputMetadata(),
+                )
 
+        # Check if the command is a single command or multiple commands
         splited_commands = split_bash_commands(command)
         if len(splited_commands) > 1:
             return ErrorObservation(
@@ -491,46 +497,62 @@ def execute(self, action: CmdRunAction) -> CmdOutputObservation | ErrorObservati
         last_change_time = start_time
         last_pane_output = self._get_pane_content()
 
-        # Do not check hard timeout if the command is a special key
-        if command != '' and is_special_key:
-            logger.debug(f'SENDING SPECIAL KEY: {command!r}')
-            self.pane.send_keys(command, enter=False)
-        # When prev command is hard timeout, and we are trying to execute new command
-        elif self.prev_status == BashCommandStatus.HARD_TIMEOUT and command != '':
-            if not last_pane_output.endswith(CMD_OUTPUT_PS1_END):
-                _ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
-                raw_command_output = self._combine_outputs_between_matches(
-                    last_pane_output, _ps1_matches
-                )
-                metadata = CmdOutputMetadata()  # No metadata available
-                metadata.suffix = (
-                    f'\n[Your command "{command}" is NOT executed. '
-                    f'The previous command was timed out but still running. Above is the output of the previous command. '
-                    "You may wait longer to see additional output of the previous command by sending empty command '', "
-                    'send other commands to interact with the current process, '
-                    'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
-                )
-                command_output = self._get_command_output(
+        # When prev command is still running, and we are trying to send a new command
+        if (
+            self.prev_status
+            in {
+                BashCommandStatus.HARD_TIMEOUT,
+                BashCommandStatus.NO_CHANGE_TIMEOUT,
+            }
+            and not last_pane_output.endswith(
+                CMD_OUTPUT_PS1_END
+            )  # prev command is not completed
+            and not is_input
+            and command != ''  # not input and not empty command
+        ):
+            _ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
+            raw_command_output = self._combine_outputs_between_matches(
+                last_pane_output, _ps1_matches
+            )
+            metadata = CmdOutputMetadata()  # No metadata available
+            metadata.suffix = (
+                f'\n[Your command "{command}" is NOT executed. '
+                f'The previous command is still running - You CANNOT send new commands until the previous command is completed. '
+                'By setting `is_input` to `true`, you can interact with the current process: '
+                "You may wait longer to see additional output of the previous command by sending empty command '', "
+                'send other commands to interact with the current process, '
+                'or send keys ("C-c", "C-z", "C-d") to interrupt/kill the previous command before sending your new command.]'
+            )
+            logger.debug(f'PREVIOUS COMMAND OUTPUT: {raw_command_output}')
+            command_output = self._get_command_output(
+                command,
+                raw_command_output,
+                metadata,
+                continue_prefix='[Below is the output of the previous command.]\n',
+            )
+            return CmdOutputObservation(
+                command=command,
+                content=command_output,
+                metadata=metadata,
+            )
+
+        # Send actual command/inputs to the pane
+        if command != '':
+            is_special_key = self._is_special_key(command)
+            if is_input:
+                logger.debug(f'SENDING INPUT TO RUNNING PROCESS: {command!r}')
+                self.pane.send_keys(
                     command,
-                    raw_command_output,
-                    metadata,
-                    continue_prefix='[Below is the output of the previous command.]\n',
+                    enter=not is_special_key,
                 )
-                return CmdOutputObservation(
-                    command=command,
-                    content=command_output,
-                    metadata=metadata,
+            else:
+                # convert command to raw string
+                command = escape_bash_special_chars(command)
+                logger.debug(f'SENDING COMMAND: {command!r}')
+                self.pane.send_keys(
+                    command,
+                    enter=not is_special_key,
                 )
-        # Only send the command to the pane if it's not a special key and it's not empty
-        # AND previous hard timeout command is resolved
-        elif command != '' and not is_special_key:
-            # convert command to raw string
-            command = escape_bash_special_chars(command)
-            logger.debug(f'SENDING COMMAND: {command!r}')
-            self.pane.send_keys(
-                command,
-                enter=True,
-            )
 
         # Loop until the command completes or times out
         while should_continue():
diff --git a/tests/runtime/test_bash.py b/tests/runtime/test_bash.py
index 828c859f11dd..4af28d9065b0 100644
--- a/tests/runtime/test_bash.py
+++ b/tests/runtime/test_bash.py
@@ -57,7 +57,7 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
             in obs.metadata.suffix
         )
 
-        action = CmdRunAction(command='C-c')
+        action = CmdRunAction(command='C-c', is_input=True)
         action.set_hard_timeout(30)
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
@@ -571,7 +571,7 @@ def test_interactive_command(temp_dir, runtime_cls, run_as_openhands):
         assert 'Enter name:' in obs.content
         assert '[The command has no new output after 1 seconds.' in obs.metadata.suffix
 
-        action = CmdRunAction('John')
+        action = CmdRunAction('John', is_input=True)
         obs = runtime.run_action(action)
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert 'Hello John' in obs.content
@@ -741,10 +741,7 @@ def test_long_running_command_follow_by_execute(
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert '3' not in obs.content
         assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
-        assert (
-            'The previous command was timed out but still running.'
-            in obs.metadata.suffix
-        )
+        assert 'The previous command is still running' in obs.metadata.suffix
         assert obs.metadata.exit_code == -1  # -1 indicates command is still running
 
         # Finally continue again
@@ -763,7 +760,9 @@ def test_empty_command_errors(temp_dir, runtime_cls, run_as_openhands):
         # Test empty command without previous command
         obs = runtime.run_action(CmdRunAction(''))
         assert isinstance(obs, CmdOutputObservation)
-        assert 'ERROR: No previous command to continue from' in obs.content
+        assert (
+            'ERROR: No previous running command to retrieve logs from.' in obs.content
+        )
     finally:
         _close_test_runtime(runtime)
 
@@ -781,13 +780,52 @@ def test_python_interactive_input(temp_dir, runtime_cls, run_as_openhands):
         assert obs.metadata.exit_code == -1  # -1 indicates command is still running
 
         # Send first input (name)
-        obs = runtime.run_action(CmdRunAction('Alice'))
+        obs = runtime.run_action(CmdRunAction('Alice', is_input=True))
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert 'Enter your age:' in obs.content
         assert obs.metadata.exit_code == -1
 
         # Send second input (age)
-        obs = runtime.run_action(CmdRunAction('25'))
+        obs = runtime.run_action(CmdRunAction('25', is_input=True))
+        logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert 'Hello Alice, you are 25 years old' in obs.content
+        assert obs.metadata.exit_code == 0
+        assert '[The command completed with exit code 0.]' in obs.metadata.suffix
+    finally:
+        _close_test_runtime(runtime)
+
+
+def test_python_interactive_input_without_set_input(
+    temp_dir, runtime_cls, run_as_openhands
+):
+    runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
+    try:
+        # Test Python program that asks for input - properly escaped for bash
+        python_script = """name = input('Enter your name: '); age = input('Enter your age: '); print(f'Hello {name}, you are {age} years old')"""
+
+        # Start Python with the interactive script
+        obs = runtime.run_action(CmdRunAction(f'python3 -c "{python_script}"'))
+        logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert 'Enter your name:' in obs.content
+        assert obs.metadata.exit_code == -1  # -1 indicates command is still running
+
+        # Send first input (name)
+        obs = runtime.run_action(CmdRunAction('Alice', is_input=False))
+        logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert 'Enter your age:' not in obs.content
+        assert (
+            'Your command "Alice" is NOT executed. The previous command is still running'
+            in obs.metadata.suffix
+        )
+        assert obs.metadata.exit_code == -1
+
+        # Try again now with input
+        obs = runtime.run_action(CmdRunAction('Alice', is_input=True))
+        logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert 'Enter your age:' in obs.content
+        assert obs.metadata.exit_code == -1
+
+        obs = runtime.run_action(CmdRunAction('25', is_input=True))
         logger.info(obs, extra={'msg_type': 'OBSERVATION'})
         assert 'Hello Alice, you are 25 years old' in obs.content
         assert obs.metadata.exit_code == 0
@@ -844,7 +882,7 @@ def test_stress_long_output_with_soft_and_hard_timeout(
             assert obs.exit_code == -1  # Command is still running, waiting for input
 
             # Send the confirmation
-            action = CmdRunAction('Y')
+            action = CmdRunAction('Y', is_input=True)
             obs = runtime.run_action(action)
             assert 'Proceeding with operation...' in obs.content
             assert 'Operation completed successfully!' in obs.content
@@ -869,13 +907,10 @@ def test_stress_long_output_with_soft_and_hard_timeout(
             # where it will not accept any new commands.
             obs = runtime.run_action(CmdRunAction('ls'))
             assert obs.exit_code == -1
-            assert (
-                'The previous command was timed out but still running.'
-                in obs.metadata.suffix
-            )
+            assert 'The previous command is still running' in obs.metadata.suffix
 
             # We need to send a Ctrl+C to reset the terminal.
-            obs = runtime.run_action(CmdRunAction('C-c'))
+            obs = runtime.run_action(CmdRunAction('C-c', is_input=True))
             assert obs.exit_code == 130
 
             # Now make sure the terminal is in a good state
@@ -887,3 +922,25 @@ def test_stress_long_output_with_soft_and_hard_timeout(
 
     finally:
         _close_test_runtime(runtime)
+
+
+def test_bash_remove_prefix(temp_dir, runtime_cls, run_as_openhands):
+    runtime = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
+    try:
+        # create a git repo
+        action = CmdRunAction(
+            'git init && git remote add origin https://github.com/All-Hands-AI/OpenHands'
+        )
+        obs = runtime.run_action(action)
+        # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert obs.metadata.exit_code == 0
+
+        # Start Python with the interactive script
+        obs = runtime.run_action(CmdRunAction('git remote -v'))
+        # logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+        assert obs.metadata.exit_code == 0
+        assert 'https://github.com/All-Hands-AI/OpenHands' in obs.content
+        assert 'git remote -v' not in obs.content
+
+    finally:
+        _close_test_runtime(runtime)
diff --git a/tests/unit/test_action_serialization.py b/tests/unit/test_action_serialization.py
index 374d5eee0100..32b29e44b231 100644
--- a/tests/unit/test_action_serialization.py
+++ b/tests/unit/test_action_serialization.py
@@ -97,6 +97,7 @@ def test_cmd_run_action_serialization_deserialization():
         'args': {
             'blocking': False,
             'command': 'echo "Hello world"',
+            'is_input': False,
             'thought': '',
             'hidden': False,
             'confirmation_state': ActionConfirmationStatus.CONFIRMED,
@@ -181,3 +182,4 @@ def test_legacy_serialization():
     assert event_dict['args']['blocking'] is False
     assert event_dict['args']['command'] == 'echo "Hello world"'
     assert event_dict['args']['thought'] == ''
+    assert event_dict['args']['is_input'] is False
diff --git a/tests/unit/test_bash_session.py b/tests/unit/test_bash_session.py
index fc29eaffb2a5..e5e0bf22b726 100644
--- a/tests/unit/test_bash_session.py
+++ b/tests/unit/test_bash_session.py
@@ -1,5 +1,6 @@
 import os
 import tempfile
+import time
 
 from openhands.core.logger import openhands_logger as logger
 from openhands.events.action import CmdRunAction
@@ -91,7 +92,7 @@ def test_long_running_command_follow_by_execute():
     assert obs.metadata.prefix == ''
 
     # Continue watching output
-    obs = session.execute(CmdRunAction(''))
+    obs = session.execute(CmdRunAction('', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '2' in obs.content
     assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
@@ -107,14 +108,20 @@ def test_long_running_command_follow_by_execute():
     # Test command that produces no output
     obs = session.execute(CmdRunAction('sleep 15'))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
+    assert '3' not in obs.content
+    assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
+    assert 'The previous command is still running' in obs.metadata.suffix
+    assert obs.metadata.exit_code == -1  # -1 indicates command is still running
+    assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
+
+    time.sleep(3)
+
+    # Run it again, this time it should produce output
+    obs = session.execute(CmdRunAction('sleep 15'))
+    logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '3' in obs.content
     assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
-    assert obs.metadata.suffix == (
-        '\n[The command has no new output after 2 seconds. '
-        "You may wait longer to see additional output by sending empty command '', "
-        'send other commands to interact with the current process, '
-        'or send keys to interrupt/kill the command.]'
-    )
+    assert 'The previous command is still running' in obs.metadata.suffix
     assert obs.metadata.exit_code == -1  # -1 indicates command is still running
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
@@ -144,7 +151,7 @@ def test_interactive_command():
     assert obs.metadata.prefix == ''
 
     # Send input
-    obs = session.execute(CmdRunAction('John'))
+    obs = session.execute(CmdRunAction('John', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert 'Hello John' in obs.content
     assert obs.metadata.exit_code == 0
@@ -165,7 +172,7 @@ def test_interactive_command():
     )
     assert obs.metadata.prefix == ''
 
-    obs = session.execute(CmdRunAction('line 1'))
+    obs = session.execute(CmdRunAction('line 1', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert obs.metadata.exit_code == -1
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
@@ -177,7 +184,7 @@ def test_interactive_command():
     )
     assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
 
-    obs = session.execute(CmdRunAction('line 2'))
+    obs = session.execute(CmdRunAction('line 2', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert obs.metadata.exit_code == -1
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
@@ -189,7 +196,7 @@ def test_interactive_command():
     )
     assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
 
-    obs = session.execute(CmdRunAction('EOF'))
+    obs = session.execute(CmdRunAction('EOF', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert 'line 1' in obs.content and 'line 2' in obs.content
     assert obs.metadata.exit_code == 0
@@ -220,7 +227,7 @@ def test_ctrl_c():
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
     # Send Ctrl+C
-    obs = session.execute(CmdRunAction('C-c'))
+    obs = session.execute(CmdRunAction('C-c', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert obs.metadata.exit_code == 130  # Standard exit code for Ctrl+C
     assert (
@@ -240,10 +247,7 @@ def test_empty_command_errors():
     # Test empty command without previous command
     obs = session.execute(CmdRunAction(''))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
-    assert (
-        obs.content
-        == 'ERROR: No previous command to continue from. Previous command has to be timeout to be continued.'
-    )
+    assert obs.content == 'ERROR: No previous running command to retrieve logs from.'
     assert obs.metadata.exit_code == -1
     assert obs.metadata.prefix == ''
     assert obs.metadata.suffix == ''
@@ -264,14 +268,14 @@ def test_command_output_continuation():
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
-    obs = session.execute(CmdRunAction(''))
+    obs = session.execute(CmdRunAction('', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '[Below is the output of the previous command.]' in obs.metadata.prefix
     assert obs.content.strip() == '2'
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
-    obs = session.execute(CmdRunAction(''))
+    obs = session.execute(CmdRunAction('', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '[Below is the output of the previous command.]' in obs.metadata.prefix
     assert obs.content.strip() == '3'
@@ -279,21 +283,21 @@ def test_command_output_continuation():
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
-    obs = session.execute(CmdRunAction(''))
+    obs = session.execute(CmdRunAction('', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '[Below is the output of the previous command.]' in obs.metadata.prefix
     assert obs.content.strip() == '4'
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
-    obs = session.execute(CmdRunAction(''))
+    obs = session.execute(CmdRunAction('', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '[Below is the output of the previous command.]' in obs.metadata.prefix
     assert obs.content.strip() == '5'
     assert '[The command has no new output after 2 seconds.' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
-    obs = session.execute(CmdRunAction(''))
+    obs = session.execute(CmdRunAction('', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert '[The command completed with exit code 0.]' in obs.metadata.suffix
     assert session.prev_status == BashCommandStatus.COMPLETED
@@ -367,14 +371,14 @@ def test_python_interactive_input():
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
     # Send first input (name)
-    obs = session.execute(CmdRunAction('Alice'))
+    obs = session.execute(CmdRunAction('Alice', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert 'Enter your age:' in obs.content
     assert obs.metadata.exit_code == -1
     assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
 
     # Send second input (age)
-    obs = session.execute(CmdRunAction('25'))
+    obs = session.execute(CmdRunAction('25', is_input=True))
     logger.info(obs, extra={'msg_type': 'OBSERVATION'})
     assert 'Hello Alice, you are 25 years old' in obs.content
     assert obs.metadata.exit_code == 0
diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py
index 0e2b3c8a0d07..b3d7ce748db1 100644
--- a/tests/unit/test_security.py
+++ b/tests/unit/test_security.py
@@ -367,6 +367,7 @@ async def test_unsafe_bash_command(temp_dir: str):
                         arguments={
                             'blocking': False,
                             'command': 'ls',
+                            'is_input': False,
                             'hidden': False,
                             'confirmation_state': ActionConfirmationStatus.CONFIRMED,
                         },

From a12087243a9d5ba8651af5ff6dd9ac88aa758157 Mon Sep 17 00:00:00 2001
From: Calvin Smith <email@cjsmith.io>
Date: Fri, 17 Jan 2025 12:33:22 -0700
Subject: [PATCH 25/39] Pydantic-based configuration and setting objects
 (#6321)

Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
---
 .../benchmarks/the_agent_company/run_infer.py |   4 +-
 evaluation/utils/shared.py                    |  43 +-----
 openhands/core/config/README.md               |  10 +-
 openhands/core/config/agent_config.py         |  33 ++---
 openhands/core/config/app_config.py           | 107 +++++---------
 openhands/core/config/config_utils.py         |  27 +++-
 openhands/core/config/llm_config.py           | 139 ++++++------------
 openhands/core/config/sandbox_config.py       |  76 ++++------
 openhands/core/config/security_config.py      |  35 +----
 openhands/core/config/utils.py                |  71 +++++----
 openhands/llm/async_llm.py                    |   4 +-
 openhands/llm/llm.py                          |   8 +-
 openhands/llm/streaming_llm.py                |   4 +-
 openhands/runtime/impl/modal/modal_runtime.py |   3 +-
 .../runtime/impl/runloop/runloop_runtime.py   |   2 +-
 openhands/server/routes/public.py             |   4 +-
 openhands/server/routes/settings.py           |   3 -
 .../server/session/conversation_init_data.py  |   7 +-
 openhands/server/session/session.py           |   3 +
 openhands/server/settings.py                  |  22 ++-
 .../storage/settings/file_settings_store.py   |   2 +-
 openhands/utils/embeddings.py                 |   4 +-
 tests/unit/test_acompletion.py                |  10 --
 tests/unit/test_codeact_agent.py              |   9 --
 tests/unit/test_condenser.py                  |   4 +-
 tests/unit/test_config.py                     |  59 ++++----
 tests/unit/test_file_settings_store.py        |  10 +-
 tests/unit/test_llm.py                        |   4 +-
 tests/unit/test_llm_config.py                 |  24 +--
 tests/unit/test_llm_draft_config.py           |   2 +-
 tests/unit/test_settings_api.py               |  36 ++++-
 31 files changed, 337 insertions(+), 432 deletions(-)

diff --git a/evaluation/benchmarks/the_agent_company/run_infer.py b/evaluation/benchmarks/the_agent_company/run_infer.py
index 8f8a1b599e6f..376df6c47cfb 100644
--- a/evaluation/benchmarks/the_agent_company/run_infer.py
+++ b/evaluation/benchmarks/the_agent_company/run_infer.py
@@ -80,7 +80,7 @@ def load_dependencies(runtime: Runtime) -> List[str]:
 def init_task_env(runtime: Runtime, hostname: str, env_llm_config: LLMConfig):
     command = (
         f'SERVER_HOSTNAME={hostname} '
-        f'LITELLM_API_KEY={env_llm_config.api_key} '
+        f'LITELLM_API_KEY={env_llm_config.api_key.get_secret_value() if env_llm_config.api_key else None} '
         f'LITELLM_BASE_URL={env_llm_config.base_url} '
         f'LITELLM_MODEL={env_llm_config.model} '
         'bash /utils/init.sh'
@@ -165,7 +165,7 @@ def run_evaluator(
     runtime: Runtime, env_llm_config: LLMConfig, trajectory_path: str, result_path: str
 ):
     command = (
-        f'LITELLM_API_KEY={env_llm_config.api_key} '
+        f'LITELLM_API_KEY={env_llm_config.api_key.get_secret_value() if env_llm_config.api_key else None} '
         f'LITELLM_BASE_URL={env_llm_config.base_url} '
         f'LITELLM_MODEL={env_llm_config.model} '
         f"DECRYPTION_KEY='theagentcompany is all you need' "  # Hardcoded Key
diff --git a/evaluation/utils/shared.py b/evaluation/utils/shared.py
index 5b3ce8c8bd4d..5eafac1db61e 100644
--- a/evaluation/utils/shared.py
+++ b/evaluation/utils/shared.py
@@ -52,30 +52,6 @@ class EvalMetadata(BaseModel):
     details: dict[str, Any] | None = None
     condenser_config: CondenserConfig | None = None
 
-    def model_dump(self, *args, **kwargs):
-        dumped_dict = super().model_dump(*args, **kwargs)
-        # avoid leaking sensitive information
-        dumped_dict['llm_config'] = self.llm_config.to_safe_dict()
-        if hasattr(self.condenser_config, 'llm_config'):
-            dumped_dict['condenser_config']['llm_config'] = (
-                self.condenser_config.llm_config.to_safe_dict()
-            )
-
-        return dumped_dict
-
-    def model_dump_json(self, *args, **kwargs):
-        dumped = super().model_dump_json(*args, **kwargs)
-        dumped_dict = json.loads(dumped)
-        # avoid leaking sensitive information
-        dumped_dict['llm_config'] = self.llm_config.to_safe_dict()
-        if hasattr(self.condenser_config, 'llm_config'):
-            dumped_dict['condenser_config']['llm_config'] = (
-                self.condenser_config.llm_config.to_safe_dict()
-            )
-
-        logger.debug(f'Dumped metadata: {dumped_dict}')
-        return json.dumps(dumped_dict)
-
 
 class EvalOutput(BaseModel):
     # NOTE: User-specified
@@ -98,23 +74,6 @@ class EvalOutput(BaseModel):
     # Optionally save the input test instance
     instance: dict[str, Any] | None = None
 
-    def model_dump(self, *args, **kwargs):
-        dumped_dict = super().model_dump(*args, **kwargs)
-        # Remove None values
-        dumped_dict = {k: v for k, v in dumped_dict.items() if v is not None}
-        # Apply custom serialization for metadata (to avoid leaking sensitive information)
-        if self.metadata is not None:
-            dumped_dict['metadata'] = self.metadata.model_dump()
-        return dumped_dict
-
-    def model_dump_json(self, *args, **kwargs):
-        dumped = super().model_dump_json(*args, **kwargs)
-        dumped_dict = json.loads(dumped)
-        # Apply custom serialization for metadata (to avoid leaking sensitive information)
-        if 'metadata' in dumped_dict:
-            dumped_dict['metadata'] = json.loads(self.metadata.model_dump_json())
-        return json.dumps(dumped_dict)
-
 
 class EvalException(Exception):
     pass
@@ -314,7 +273,7 @@ def update_progress(
     logger.info(
         f'Finished evaluation for instance {result.instance_id}: {str(result.test_result)[:300]}...\n'
     )
-    output_fp.write(json.dumps(result.model_dump()) + '\n')
+    output_fp.write(result.model_dump_json() + '\n')
     output_fp.flush()
 
 
diff --git a/openhands/core/config/README.md b/openhands/core/config/README.md
index 5e3abae5b13a..c612a0824403 100644
--- a/openhands/core/config/README.md
+++ b/openhands/core/config/README.md
@@ -37,21 +37,17 @@ export SANDBOX_TIMEOUT='300'
 
 ## Type Handling
 
-The `load_from_env` function attempts to cast environment variable values to the types specified in the dataclasses. It handles:
+The `load_from_env` function attempts to cast environment variable values to the types specified in the models. It handles:
 
 - Basic types (str, int, bool)
 - Optional types (e.g., `str | None`)
-- Nested dataclasses
+- Nested models
 
 If type casting fails, an error is logged, and the default value is retained.
 
 ## Default Values
 
-If an environment variable is not set, the default value specified in the dataclass is used.
-
-## Nested Configurations
-
-The `AppConfig` class contains nested configurations like `LLMConfig` and `AgentConfig`. The `load_from_env` function handles these by recursively processing nested dataclasses with updated prefixes.
+If an environment variable is not set, the default value specified in the model is used.
 
 ## Security Considerations
 
diff --git a/openhands/core/config/agent_config.py b/openhands/core/config/agent_config.py
index 375fd9b12e8a..43e430b879da 100644
--- a/openhands/core/config/agent_config.py
+++ b/openhands/core/config/agent_config.py
@@ -1,11 +1,9 @@
-from dataclasses import dataclass, field, fields
+from pydantic import BaseModel, Field
 
 from openhands.core.config.condenser_config import CondenserConfig, NoOpCondenserConfig
-from openhands.core.config.config_utils import get_field_info
 
 
-@dataclass
-class AgentConfig:
+class AgentConfig(BaseModel):
     """Configuration for the agent.
 
     Attributes:
@@ -22,20 +20,13 @@ class AgentConfig:
         condenser: Configuration for the memory condenser. Default is NoOpCondenserConfig.
     """
 
-    codeact_enable_browsing: bool = True
-    codeact_enable_llm_editor: bool = False
-    codeact_enable_jupyter: bool = True
-    micro_agent_name: str | None = None
-    memory_enabled: bool = False
-    memory_max_threads: int = 3
-    llm_config: str | None = None
-    enable_prompt_extensions: bool = True
-    disabled_microagents: list[str] | None = None
-    condenser: CondenserConfig = field(default_factory=NoOpCondenserConfig)  # type: ignore
-
-    def defaults_to_dict(self) -> dict:
-        """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
-        result = {}
-        for f in fields(self):
-            result[f.name] = get_field_info(f)
-        return result
+    codeact_enable_browsing: bool = Field(default=True)
+    codeact_enable_llm_editor: bool = Field(default=False)
+    codeact_enable_jupyter: bool = Field(default=True)
+    micro_agent_name: str | None = Field(default=None)
+    memory_enabled: bool = Field(default=False)
+    memory_max_threads: int = Field(default=3)
+    llm_config: str | None = Field(default=None)
+    enable_prompt_extensions: bool = Field(default=False)
+    disabled_microagents: list[str] | None = Field(default=None)
+    condenser: CondenserConfig = Field(default_factory=NoOpCondenserConfig)
diff --git a/openhands/core/config/app_config.py b/openhands/core/config/app_config.py
index a59f4fb1b865..468d37572fe2 100644
--- a/openhands/core/config/app_config.py
+++ b/openhands/core/config/app_config.py
@@ -1,20 +1,20 @@
-from dataclasses import dataclass, field, fields, is_dataclass
 from typing import ClassVar
 
+from pydantic import BaseModel, Field, SecretStr
+
 from openhands.core import logger
 from openhands.core.config.agent_config import AgentConfig
 from openhands.core.config.config_utils import (
     OH_DEFAULT_AGENT,
     OH_MAX_ITERATIONS,
-    get_field_info,
+    model_defaults_to_dict,
 )
 from openhands.core.config.llm_config import LLMConfig
 from openhands.core.config.sandbox_config import SandboxConfig
 from openhands.core.config.security_config import SecurityConfig
 
 
-@dataclass
-class AppConfig:
+class AppConfig(BaseModel):
     """Configuration for the app.
 
     Attributes:
@@ -46,37 +46,39 @@ class AppConfig:
             input is read line by line. When enabled, input continues until /exit command.
     """
 
-    llms: dict[str, LLMConfig] = field(default_factory=dict)
-    agents: dict = field(default_factory=dict)
-    default_agent: str = OH_DEFAULT_AGENT
-    sandbox: SandboxConfig = field(default_factory=SandboxConfig)
-    security: SecurityConfig = field(default_factory=SecurityConfig)
-    runtime: str = 'docker'
-    file_store: str = 'local'
-    file_store_path: str = '/tmp/openhands_file_store'
-    save_trajectory_path: str | None = None
-    workspace_base: str | None = None
-    workspace_mount_path: str | None = None
-    workspace_mount_path_in_sandbox: str = '/workspace'
-    workspace_mount_rewrite: str | None = None
-    cache_dir: str = '/tmp/cache'
-    run_as_openhands: bool = True
-    max_iterations: int = OH_MAX_ITERATIONS
-    max_budget_per_task: float | None = None
-    e2b_api_key: str = ''
-    modal_api_token_id: str = ''
-    modal_api_token_secret: str = ''
-    disable_color: bool = False
-    jwt_secret: str = ''
-    debug: bool = False
-    file_uploads_max_file_size_mb: int = 0
-    file_uploads_restrict_file_types: bool = False
-    file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*'])
-    runloop_api_key: str | None = None
-    cli_multiline_input: bool = False
+    llms: dict[str, LLMConfig] = Field(default_factory=dict)
+    agents: dict = Field(default_factory=dict)
+    default_agent: str = Field(default=OH_DEFAULT_AGENT)
+    sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
+    security: SecurityConfig = Field(default_factory=SecurityConfig)
+    runtime: str = Field(default='docker')
+    file_store: str = Field(default='local')
+    file_store_path: str = Field(default='/tmp/openhands_file_store')
+    save_trajectory_path: str | None = Field(default=None)
+    workspace_base: str | None = Field(default=None)
+    workspace_mount_path: str | None = Field(default=None)
+    workspace_mount_path_in_sandbox: str = Field(default='/workspace')
+    workspace_mount_rewrite: str | None = Field(default=None)
+    cache_dir: str = Field(default='/tmp/cache')
+    run_as_openhands: bool = Field(default=True)
+    max_iterations: int = Field(default=OH_MAX_ITERATIONS)
+    max_budget_per_task: float | None = Field(default=None)
+    e2b_api_key: SecretStr | None = Field(default=None)
+    modal_api_token_id: SecretStr | None = Field(default=None)
+    modal_api_token_secret: SecretStr | None = Field(default=None)
+    disable_color: bool = Field(default=False)
+    jwt_secret: SecretStr | None = Field(default=None)
+    debug: bool = Field(default=False)
+    file_uploads_max_file_size_mb: int = Field(default=0)
+    file_uploads_restrict_file_types: bool = Field(default=False)
+    file_uploads_allowed_extensions: list[str] = Field(default_factory=lambda: ['.*'])
+    runloop_api_key: SecretStr | None = Field(default=None)
+    cli_multiline_input: bool = Field(default=False)
 
     defaults_dict: ClassVar[dict] = {}
 
+    model_config = {'extra': 'forbid'}
+
     def get_llm_config(self, name='llm') -> LLMConfig:
         """'llm' is the name for default config (for backward compatibility prior to 0.8)."""
         if name in self.llms:
@@ -115,42 +117,7 @@ def get_llm_config_from_agent(self, name='agent') -> LLMConfig:
     def get_agent_configs(self) -> dict[str, AgentConfig]:
         return self.agents
 
-    def __post_init__(self):
+    def model_post_init(self, __context):
         """Post-initialization hook, called when the instance is created with only default values."""
-        AppConfig.defaults_dict = self.defaults_to_dict()
-
-    def defaults_to_dict(self) -> dict:
-        """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
-        result = {}
-        for f in fields(self):
-            field_value = getattr(self, f.name)
-
-            # dataclasses compute their defaults themselves
-            if is_dataclass(type(field_value)):
-                result[f.name] = field_value.defaults_to_dict()
-            else:
-                result[f.name] = get_field_info(f)
-        return result
-
-    def __str__(self):
-        attr_str = []
-        for f in fields(self):
-            attr_name = f.name
-            attr_value = getattr(self, f.name)
-
-            if attr_name in [
-                'e2b_api_key',
-                'github_token',
-                'jwt_secret',
-                'modal_api_token_id',
-                'modal_api_token_secret',
-                'runloop_api_key',
-            ]:
-                attr_value = '******' if attr_value else None
-
-            attr_str.append(f'{attr_name}={repr(attr_value)}')
-
-        return f"AppConfig({', '.join(attr_str)}"
-
-    def __repr__(self):
-        return self.__str__()
+        super().model_post_init(__context)
+        AppConfig.defaults_dict = model_defaults_to_dict(self)
diff --git a/openhands/core/config/config_utils.py b/openhands/core/config/config_utils.py
index 38c3c1d03df5..44893e119b5a 100644
--- a/openhands/core/config/config_utils.py
+++ b/openhands/core/config/config_utils.py
@@ -1,19 +1,22 @@
 from types import UnionType
-from typing import get_args, get_origin
+from typing import Any, get_args, get_origin
+
+from pydantic import BaseModel
+from pydantic.fields import FieldInfo
 
 OH_DEFAULT_AGENT = 'CodeActAgent'
 OH_MAX_ITERATIONS = 500
 
 
-def get_field_info(f):
+def get_field_info(field: FieldInfo) -> dict[str, Any]:
     """Extract information about a dataclass field: type, optional, and default.
 
     Args:
-        f: The field to extract information from.
+        field: The field to extract information from.
 
     Returns: A dict with the field's type, whether it's optional, and its default value.
     """
-    field_type = f.type
+    field_type = field.annotation
     optional = False
 
     # for types like str | None, find the non-None type and set optional to True
@@ -33,7 +36,21 @@ def get_field_info(f):
     )
 
     # default is always present
-    default = f.default
+    default = field.default
 
     # return a schema with the useful info for frontend
     return {'type': type_name.lower(), 'optional': optional, 'default': default}
+
+
+def model_defaults_to_dict(model: BaseModel) -> dict[str, Any]:
+    """Serialize field information in a dict for the frontend, including type hints, defaults, and whether it's optional."""
+    result = {}
+    for name, field in model.model_fields.items():
+        field_value = getattr(model, name)
+
+        if isinstance(field_value, BaseModel):
+            result[name] = model_defaults_to_dict(field_value)
+        else:
+            result[name] = get_field_info(field)
+
+    return result
diff --git a/openhands/core/config/llm_config.py b/openhands/core/config/llm_config.py
index 2687d0206940..c28390d47bda 100644
--- a/openhands/core/config/llm_config.py
+++ b/openhands/core/config/llm_config.py
@@ -1,14 +1,14 @@
+from __future__ import annotations
+
 import os
-from dataclasses import dataclass, fields
 
-from openhands.core.config.config_utils import get_field_info
-from openhands.core.logger import LOG_DIR
+from typing import Any
+from pydantic import BaseModel, Field, SecretStr
 
-LLM_SENSITIVE_FIELDS = ['api_key', 'aws_access_key_id', 'aws_secret_access_key']
+from openhands.core.logger import LOG_DIR
 
 
-@dataclass
-class LLMConfig:
+class LLMConfig(BaseModel):
     """Configuration for the LLM model.
 
     Attributes:
@@ -47,99 +47,56 @@ class LLMConfig:
         native_tool_calling: Whether to use native tool calling if supported by the model. Can be True, False, or not set.
     """
 
-    model: str = 'claude-3-5-sonnet-20241022'
-    api_key: str | None = None
-    base_url: str | None = None
-    api_version: str | None = None
-    embedding_model: str = 'local'
-    embedding_base_url: str | None = None
-    embedding_deployment_name: str | None = None
-    aws_access_key_id: str | None = None
-    aws_secret_access_key: str | None = None
-    aws_region_name: str | None = None
-    openrouter_site_url: str = 'https://docs.all-hands.dev/'
-    openrouter_app_name: str = 'OpenHands'
-    num_retries: int = 8
-    retry_multiplier: float = 2
-    retry_min_wait: int = 15
-    retry_max_wait: int = 120
-    timeout: int | None = None
-    max_message_chars: int = 30_000  # maximum number of characters in an observation's content when sent to the llm
-    temperature: float = 0.0
-    top_p: float = 1.0
-    custom_llm_provider: str | None = None
-    max_input_tokens: int | None = None
-    max_output_tokens: int | None = None
-    input_cost_per_token: float | None = None
-    output_cost_per_token: float | None = None
-    ollama_base_url: str | None = None
+    model: str = Field(default='claude-3-5-sonnet-20241022')
+    api_key: SecretStr | None = Field(default=None)
+    base_url: str | None = Field(default=None)
+    api_version: str | None = Field(default=None)
+    embedding_model: str = Field(default='local')
+    embedding_base_url: str | None = Field(default=None)
+    embedding_deployment_name: str | None = Field(default=None)
+    aws_access_key_id: SecretStr | None = Field(default=None)
+    aws_secret_access_key: SecretStr | None = Field(default=None)
+    aws_region_name: str | None = Field(default=None)
+    openrouter_site_url: str = Field(default='https://docs.all-hands.dev/')
+    openrouter_app_name: str = Field(default='OpenHands')
+    num_retries: int = Field(default=8)
+    retry_multiplier: float = Field(default=2)
+    retry_min_wait: int = Field(default=15)
+    retry_max_wait: int = Field(default=120)
+    timeout: int | None = Field(default=None)
+    max_message_chars: int = Field(
+        default=30_000
+    )  # maximum number of characters in an observation's content when sent to the llm
+    temperature: float = Field(default=0.0)
+    top_p: float = Field(default=1.0)
+    custom_llm_provider: str | None = Field(default=None)
+    max_input_tokens: int | None = Field(default=None)
+    max_output_tokens: int | None = Field(default=None)
+    input_cost_per_token: float | None = Field(default=None)
+    output_cost_per_token: float | None = Field(default=None)
+    ollama_base_url: str | None = Field(default=None)
     # This setting can be sent in each call to litellm
-    drop_params: bool = True
+    drop_params: bool = Field(default=True)
     # Note: this setting is actually global, unlike drop_params
-    modify_params: bool = True
-    disable_vision: bool | None = None
-    reasoning_effort: str | None = None
-    caching_prompt: bool = True
-    log_completions: bool = False
-    log_completions_folder: str = os.path.join(LOG_DIR, 'completions')
-    custom_tokenizer: str | None = None
-    native_tool_calling: bool | None = None
-
-    def defaults_to_dict(self) -> dict:
-        """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
-        result = {}
-        for f in fields(self):
-            result[f.name] = get_field_info(f)
-        return result
+    modify_params: bool = Field(default=True)
+    disable_vision: bool | None = Field(default=None)
+    caching_prompt: bool = Field(default=True)
+    log_completions: bool = Field(default=False)
+    log_completions_folder: str = Field(default=os.path.join(LOG_DIR, 'completions'))
+    custom_tokenizer: str | None = Field(default=None)
+    native_tool_calling: bool | None = Field(default=None)
+    
+    model_config = {'extra': 'forbid'}
+
+    def model_post_init(self, __context: Any):
+        """Post-initialization hook to assign OpenRouter-related variables to environment variables.
 
-    def __post_init__(self):
-        """
-        Post-initialization hook to assign OpenRouter-related variables to environment variables.
         This ensures that these values are accessible to litellm at runtime.
         """
+        super().model_post_init(__context)
 
         # Assign OpenRouter-specific variables to environment variables
         if self.openrouter_site_url:
             os.environ['OR_SITE_URL'] = self.openrouter_site_url
         if self.openrouter_app_name:
             os.environ['OR_APP_NAME'] = self.openrouter_app_name
-
-    def __str__(self):
-        attr_str = []
-        for f in fields(self):
-            attr_name = f.name
-            attr_value = getattr(self, f.name)
-
-            if attr_name in LLM_SENSITIVE_FIELDS:
-                attr_value = '******' if attr_value else None
-
-            attr_str.append(f'{attr_name}={repr(attr_value)}')
-
-        return f"LLMConfig({', '.join(attr_str)})"
-
-    def __repr__(self):
-        return self.__str__()
-
-    def to_safe_dict(self):
-        """Return a dict with the sensitive fields replaced with ******."""
-        ret = self.__dict__.copy()
-        for k, v in ret.items():
-            if k in LLM_SENSITIVE_FIELDS:
-                ret[k] = '******' if v else None
-            elif isinstance(v, LLMConfig):
-                ret[k] = v.to_safe_dict()
-        return ret
-
-    @classmethod
-    def from_dict(cls, llm_config_dict: dict) -> 'LLMConfig':
-        """Create an LLMConfig object from a dictionary.
-
-        This function is used to create an LLMConfig object from a dictionary.
-        """
-        # Keep None values to preserve defaults, filter out other dicts
-        args = {
-            k: v
-            for k, v in llm_config_dict.items()
-            if not isinstance(v, dict) or v is None
-        }
-        return cls(**args)
diff --git a/openhands/core/config/sandbox_config.py b/openhands/core/config/sandbox_config.py
index 0ea40f29faab..44bd7ee0afda 100644
--- a/openhands/core/config/sandbox_config.py
+++ b/openhands/core/config/sandbox_config.py
@@ -1,11 +1,9 @@
 import os
-from dataclasses import dataclass, field, fields
 
-from openhands.core.config.config_utils import get_field_info
+from pydantic import BaseModel, Field
 
 
-@dataclass
-class SandboxConfig:
+class SandboxConfig(BaseModel):
     """Configuration for the sandbox.
 
     Attributes:
@@ -39,48 +37,32 @@ class SandboxConfig:
             This should be a JSON string that will be parsed into a dictionary.
     """
 
-    remote_runtime_api_url: str = 'http://localhost:8000'
-    local_runtime_url: str = 'http://localhost'
-    keep_runtime_alive: bool = False
-    rm_all_containers: bool = False
-    api_key: str | None = None
-    base_container_image: str = 'nikolaik/python-nodejs:python3.12-nodejs22'  # default to nikolaik/python-nodejs:python3.12-nodejs22 for eventstream runtime
-    runtime_container_image: str | None = None
-    user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000
-    timeout: int = 120
-    remote_runtime_init_timeout: int = 180
-    enable_auto_lint: bool = (
-        False  # once enabled, OpenHands would lint files after editing
+    remote_runtime_api_url: str = Field(default='http://localhost:8000')
+    local_runtime_url: str = Field(default='http://localhost')
+    keep_runtime_alive: bool = Field(default=False)
+    rm_all_containers: bool = Field(default=False)
+    api_key: str | None = Field(default=None)
+    base_container_image: str = Field(
+        default='nikolaik/python-nodejs:python3.12-nodejs22'
     )
-    use_host_network: bool = False
-    runtime_extra_build_args: list[str] | None = None
-    initialize_plugins: bool = True
-    force_rebuild_runtime: bool = False
-    runtime_extra_deps: str | None = None
-    runtime_startup_env_vars: dict[str, str] = field(default_factory=dict)
-    browsergym_eval_env: str | None = None
-    platform: str | None = None
-    close_delay: int = 15
-    remote_runtime_resource_factor: int = 1
-    enable_gpu: bool = False
-    docker_runtime_kwargs: str | None = None
-
-    def defaults_to_dict(self) -> dict:
-        """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
-        dict = {}
-        for f in fields(self):
-            dict[f.name] = get_field_info(f)
-        return dict
-
-    def __str__(self):
-        attr_str = []
-        for f in fields(self):
-            attr_name = f.name
-            attr_value = getattr(self, f.name)
-
-            attr_str.append(f'{attr_name}={repr(attr_value)}')
-
-        return f"SandboxConfig({', '.join(attr_str)})"
+    runtime_container_image: str | None = Field(default=None)
+    user_id: int = Field(default=os.getuid() if hasattr(os, 'getuid') else 1000)
+    timeout: int = Field(default=120)
+    remote_runtime_init_timeout: int = Field(default=180)
+    enable_auto_lint: bool = Field(
+        default=False  # once enabled, OpenHands would lint files after editing
+    )
+    use_host_network: bool = Field(default=False)
+    runtime_extra_build_args: list[str] | None = Field(default=None)
+    initialize_plugins: bool = Field(default=True)
+    force_rebuild_runtime: bool = Field(default=False)
+    runtime_extra_deps: str | None = Field(default=None)
+    runtime_startup_env_vars: dict[str, str] = Field(default_factory=dict)
+    browsergym_eval_env: str | None = Field(default=None)
+    platform: str | None = Field(default=None)
+    close_delay: int = Field(default=900)
+    remote_runtime_resource_factor: int = Field(default=1)
+    enable_gpu: bool = Field(default=False)
+    docker_runtime_kwargs: str | None = Field(default=None)
 
-    def __repr__(self):
-        return self.__str__()
+    model_config = {'extra': 'forbid'}
diff --git a/openhands/core/config/security_config.py b/openhands/core/config/security_config.py
index 60645f305736..a4805e3ab85f 100644
--- a/openhands/core/config/security_config.py
+++ b/openhands/core/config/security_config.py
@@ -1,10 +1,7 @@
-from dataclasses import dataclass, fields
+from pydantic import BaseModel, Field
 
-from openhands.core.config.config_utils import get_field_info
 
-
-@dataclass
-class SecurityConfig:
+class SecurityConfig(BaseModel):
     """Configuration for security related functionalities.
 
     Attributes:
@@ -12,29 +9,5 @@ class SecurityConfig:
         security_analyzer: The security analyzer to use.
     """
 
-    confirmation_mode: bool = False
-    security_analyzer: str | None = None
-
-    def defaults_to_dict(self) -> dict:
-        """Serialize fields to a dict for the frontend, including type hints, defaults, and whether it's optional."""
-        dict = {}
-        for f in fields(self):
-            dict[f.name] = get_field_info(f)
-        return dict
-
-    def __str__(self):
-        attr_str = []
-        for f in fields(self):
-            attr_name = f.name
-            attr_value = getattr(self, f.name)
-
-            attr_str.append(f'{attr_name}={repr(attr_value)}')
-
-        return f"SecurityConfig({', '.join(attr_str)})"
-
-    @classmethod
-    def from_dict(cls, security_config_dict: dict) -> 'SecurityConfig':
-        return cls(**security_config_dict)
-
-    def __repr__(self):
-        return self.__str__()
+    confirmation_mode: bool = Field(default=False)
+    security_analyzer: str | None = Field(default=None)
diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py
index ccdd9494a84d..f543ab9682ac 100644
--- a/openhands/core/config/utils.py
+++ b/openhands/core/config/utils.py
@@ -3,13 +3,13 @@
 import pathlib
 import platform
 import sys
-from dataclasses import is_dataclass
 from types import UnionType
 from typing import Any, MutableMapping, get_args, get_origin
 from uuid import uuid4
 
 import toml
 from dotenv import load_dotenv
+from pydantic import BaseModel, ValidationError
 
 from openhands.core import logger
 from openhands.core.config.agent_config import AgentConfig
@@ -43,17 +43,19 @@ def get_optional_type(union_type: UnionType) -> Any:
         return next((t for t in types if t is not type(None)), None)
 
     # helper function to set attributes based on env vars
-    def set_attr_from_env(sub_config: Any, prefix=''):
-        """Set attributes of a config dataclass based on environment variables."""
-        for field_name, field_type in sub_config.__annotations__.items():
+    def set_attr_from_env(sub_config: BaseModel, prefix=''):
+        """Set attributes of a config model based on environment variables."""
+        for field_name, field_info in sub_config.model_fields.items():
+            field_value = getattr(sub_config, field_name)
+            field_type = field_info.annotation
+
             # compute the expected env var name from the prefix and field name
             # e.g. LLM_BASE_URL
             env_var_name = (prefix + field_name).upper()
 
-            if is_dataclass(field_type):
-                # nested dataclass
-                nested_sub_config = getattr(sub_config, field_name)
-                set_attr_from_env(nested_sub_config, prefix=field_name + '_')
+            if isinstance(field_value, BaseModel):
+                set_attr_from_env(field_value, prefix=field_name + '_')
+
             elif env_var_name in env_or_toml_dict:
                 # convert the env var to the correct type and set it
                 value = env_or_toml_dict[env_var_name]
@@ -125,22 +127,40 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
         if isinstance(value, dict):
             try:
                 if key is not None and key.lower() == 'agent':
+                    # Every entry here is either a field for the default `agent` config group, or itself a group
+                    # The best way to tell the difference is to try to parse it as an AgentConfig object
+                    agent_group_ids: set[str] = set()
+                    for nested_key, nested_value in value.items():
+                        if isinstance(nested_value, dict):
+                            try:
+                                agent_config = AgentConfig(**nested_value)
+                            except ValidationError:
+                                continue
+                            agent_group_ids.add(nested_key)
+                            cfg.set_agent_config(agent_config, nested_key)
+
                     logger.openhands_logger.debug(
                         'Attempt to load default agent config from config toml'
                     )
-                    non_dict_fields = {
-                        k: v for k, v in value.items() if not isinstance(v, dict)
+                    value_without_groups = {
+                        k: v for k, v in value.items() if k not in agent_group_ids
                     }
-                    agent_config = AgentConfig(**non_dict_fields)
+                    agent_config = AgentConfig(**value_without_groups)
                     cfg.set_agent_config(agent_config, 'agent')
+
+                elif key is not None and key.lower() == 'llm':
+                    # Every entry here is either a field for the default `llm` config group, or itself a group
+                    # The best way to tell the difference is to try to parse it as an LLMConfig object
+                    llm_group_ids: set[str] = set()
                     for nested_key, nested_value in value.items():
                         if isinstance(nested_value, dict):
-                            logger.openhands_logger.debug(
-                                f'Attempt to load group {nested_key} from config toml as agent config'
-                            )
-                            agent_config = AgentConfig(**nested_value)
-                            cfg.set_agent_config(agent_config, nested_key)
-                elif key is not None and key.lower() == 'llm':
+                            try:
+                                llm_config = LLMConfig(**nested_value)
+                            except ValidationError:
+                                continue
+                            llm_group_ids.add(nested_key)
+                            cfg.set_llm_config(llm_config, nested_key)
+
                     logger.openhands_logger.debug(
                         'Attempt to load default LLM config from config toml'
                     )
@@ -150,7 +170,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
                     for k, v in value.items():
                         if not isinstance(v, dict):
                             generic_llm_fields[k] = v
-                    generic_llm_config = LLMConfig.from_dict(generic_llm_fields)
+                    generic_llm_config = LLMConfig(**generic_llm_fields)
                     cfg.set_llm_config(generic_llm_config, 'llm')
 
                     # Process custom named LLM configs
@@ -170,22 +190,23 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
                             for k, v in nested_value.items():
                                 if not isinstance(v, dict):
                                     custom_fields[k] = v
-                            merged_llm_dict = generic_llm_config.__dict__.copy()
+                            merged_llm_dict = generic_llm_fields.copy()
                             merged_llm_dict.update(custom_fields)
-
-                            custom_llm_config = LLMConfig.from_dict(merged_llm_dict)
+                            
+                            custom_llm_config = LLMConfig(**merged_llm_dict)
                             cfg.set_llm_config(custom_llm_config, nested_key)
+
                 elif key is not None and key.lower() == 'security':
                     logger.openhands_logger.debug(
                         'Attempt to load security config from config toml'
                     )
-                    security_config = SecurityConfig.from_dict(value)
+                    security_config = SecurityConfig(**value)
                     cfg.security = security_config
                 elif not key.startswith('sandbox') and key.lower() != 'core':
                     logger.openhands_logger.warning(
                         f'Unknown key in {toml_file}: "{key}"'
                     )
-            except (TypeError, KeyError) as e:
+            except (TypeError, KeyError, ValidationError) as e:
                 logger.openhands_logger.warning(
                     f'Cannot parse [{key}] config from toml, values have not been applied.\nError: {e}',
                 )
@@ -221,7 +242,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
                 logger.openhands_logger.warning(
                     f'Unknown config key "{key}" in [core] section'
                 )
-    except (TypeError, KeyError) as e:
+    except (TypeError, KeyError, ValidationError) as e:
         logger.openhands_logger.warning(
             f'Cannot parse [sandbox] config from toml, values have not been applied.\nError: {e}',
         )
@@ -324,7 +345,7 @@ def get_llm_config_arg(
 
     # update the llm config with the specified section
     if 'llm' in toml_config and llm_config_arg in toml_config['llm']:
-        return LLMConfig.from_dict(toml_config['llm'][llm_config_arg])
+        return LLMConfig(**toml_config['llm'][llm_config_arg])
     logger.openhands_logger.debug(f'Loading from toml failed for {llm_config_arg}')
     return None
 
diff --git a/openhands/llm/async_llm.py b/openhands/llm/async_llm.py
index f553ae173fd6..805240c2b19f 100644
--- a/openhands/llm/async_llm.py
+++ b/openhands/llm/async_llm.py
@@ -23,7 +23,9 @@ def __init__(self, *args, **kwargs):
         self._async_completion = partial(
             self._call_acompletion,
             model=self.config.model,
-            api_key=self.config.api_key,
+            api_key=self.config.api_key.get_secret_value()
+            if self.config.api_key
+            else None,
             base_url=self.config.base_url,
             api_version=self.config.api_version,
             custom_llm_provider=self.config.custom_llm_provider,
diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py
index 88cda96c5f00..8f9ac12b7063 100644
--- a/openhands/llm/llm.py
+++ b/openhands/llm/llm.py
@@ -141,7 +141,9 @@ def __init__(
         self._completion = partial(
             litellm_completion,
             model=self.config.model,
-            api_key=self.config.api_key,
+            api_key=self.config.api_key.get_secret_value()
+            if self.config.api_key
+            else None,
             base_url=self.config.base_url,
             api_version=self.config.api_version,
             custom_llm_provider=self.config.custom_llm_provider,
@@ -331,7 +333,9 @@ def init_model_info(self):
             # GET {base_url}/v1/model/info with litellm_model_id as path param
             response = requests.get(
                 f'{self.config.base_url}/v1/model/info',
-                headers={'Authorization': f'Bearer {self.config.api_key}'},
+                headers={
+                    'Authorization': f'Bearer {self.config.api_key.get_secret_value() if self.config.api_key else None}'
+                },
             )
             resp_json = response.json()
             if 'data' not in resp_json:
diff --git a/openhands/llm/streaming_llm.py b/openhands/llm/streaming_llm.py
index 10925b9564cf..2a0e5b2d9dbb 100644
--- a/openhands/llm/streaming_llm.py
+++ b/openhands/llm/streaming_llm.py
@@ -17,7 +17,9 @@ def __init__(self, *args, **kwargs):
         self._async_streaming_completion = partial(
             self._call_acompletion,
             model=self.config.model,
-            api_key=self.config.api_key,
+            api_key=self.config.api_key.get_secret_value()
+            if self.config.api_key
+            else None,
             base_url=self.config.base_url,
             api_version=self.config.api_version,
             custom_llm_provider=self.config.custom_llm_provider,
diff --git a/openhands/runtime/impl/modal/modal_runtime.py b/openhands/runtime/impl/modal/modal_runtime.py
index 6c2be0739615..61e72205a7f8 100644
--- a/openhands/runtime/impl/modal/modal_runtime.py
+++ b/openhands/runtime/impl/modal/modal_runtime.py
@@ -59,7 +59,8 @@ def __init__(
         self.sandbox = None
 
         self.modal_client = modal.Client.from_credentials(
-            config.modal_api_token_id, config.modal_api_token_secret
+            config.modal_api_token_id.get_secret_value(),
+            config.modal_api_token_secret.get_secret_value(),
         )
         self.app = modal.App.lookup(
             'openhands', create_if_missing=True, client=self.modal_client
diff --git a/openhands/runtime/impl/runloop/runloop_runtime.py b/openhands/runtime/impl/runloop/runloop_runtime.py
index 51628f54056d..add4619aea81 100644
--- a/openhands/runtime/impl/runloop/runloop_runtime.py
+++ b/openhands/runtime/impl/runloop/runloop_runtime.py
@@ -40,7 +40,7 @@ def __init__(
         self.devbox: DevboxView | None = None
         self.config = config
         self.runloop_api_client = Runloop(
-            bearer_token=config.runloop_api_key,
+            bearer_token=config.runloop_api_key.get_secret_value(),
         )
         self.container_name = CONTAINER_NAME_PREFIX + sid
         super().__init__(
diff --git a/openhands/server/routes/public.py b/openhands/server/routes/public.py
index a057bebb3692..ff3ab378f9e2 100644
--- a/openhands/server/routes/public.py
+++ b/openhands/server/routes/public.py
@@ -51,8 +51,8 @@ async def get_litellm_models() -> list[str]:
     ):
         bedrock_model_list = bedrock.list_foundation_models(
             llm_config.aws_region_name,
-            llm_config.aws_access_key_id,
-            llm_config.aws_secret_access_key,
+            llm_config.aws_access_key_id.get_secret_value(),
+            llm_config.aws_secret_access_key.get_secret_value(),
         )
     model_list = litellm_model_list_without_bedrock + bedrock_model_list
     for llm_config in config.llms.values():
diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py
index ca45c142ff1c..9abad14f8547 100644
--- a/openhands/server/routes/settings.py
+++ b/openhands/server/routes/settings.py
@@ -30,9 +30,6 @@ async def load_settings(request: Request) -> Settings | None:
                 status_code=status.HTTP_404_NOT_FOUND,
                 content={'error': 'Settings not found'},
             )
-
-        # For security reasons we don't ever send the api key to the client
-        settings.llm_api_key = 'SET' if settings.llm_api_key else None
         return settings
     except Exception as e:
         logger.warning(f'Invalid token: {e}')
diff --git a/openhands/server/session/conversation_init_data.py b/openhands/server/session/conversation_init_data.py
index 218d72addd14..82979e91fd96 100644
--- a/openhands/server/session/conversation_init_data.py
+++ b/openhands/server/session/conversation_init_data.py
@@ -1,13 +1,12 @@
-from dataclasses import dataclass
+from pydantic import Field
 
 from openhands.server.settings import Settings
 
 
-@dataclass
 class ConversationInitData(Settings):
     """
     Session initialization data for the web environment - a deep copy of the global config is made and then overridden with this data.
     """
 
-    github_token: str | None = None
-    selected_repository: str | None = None
+    github_token: str | None = Field(default=None)
+    selected_repository: str | None = Field(default=None)
diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py
index b24b29702086..c6a4dd31f04c 100644
--- a/openhands/server/session/session.py
+++ b/openhands/server/session/session.py
@@ -91,6 +91,9 @@ async def initialize_agent(self, settings: Settings, initial_user_msg: str | Non
         )
         max_iterations = settings.max_iterations or self.config.max_iterations
 
+        # This is a shallow copy of the default LLM config, so changes here will
+        # persist if we retrieve the default LLM config again when constructing
+        # the agent
         default_llm_config = self.config.get_llm_config()
         default_llm_config.model = settings.llm_model or ''
         default_llm_config.api_key = settings.llm_api_key
diff --git a/openhands/server/settings.py b/openhands/server/settings.py
index 57c879e49d45..bdb755fd5c20 100644
--- a/openhands/server/settings.py
+++ b/openhands/server/settings.py
@@ -1,8 +1,8 @@
-from dataclasses import dataclass
+from pydantic import BaseModel, SecretStr, SerializationInfo, field_serializer
+from pydantic.json import pydantic_encoder
 
 
-@dataclass
-class Settings:
+class Settings(BaseModel):
     """
     Persisted settings for OpenHands sessions
     """
@@ -13,6 +13,20 @@ class Settings:
     security_analyzer: str | None = None
     confirmation_mode: bool | None = None
     llm_model: str | None = None
-    llm_api_key: str | None = None
+    llm_api_key: SecretStr | None = None
     llm_base_url: str | None = None
     remote_runtime_resource_factor: int | None = None
+
+    @field_serializer('llm_api_key')
+    def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo):
+        """Custom serializer for the LLM API key.
+
+        To serialize the API key instead of `"********"`, set `expose_secrets` to True in the serialization context. For example::
+
+        settings.model_dump_json(context={'expose_secrets': True})
+        """
+        context = info.context
+        if context and context.get('expose_secrets', False):
+            return llm_api_key.get_secret_value()
+
+        return pydantic_encoder(llm_api_key)
diff --git a/openhands/storage/settings/file_settings_store.py b/openhands/storage/settings/file_settings_store.py
index e0ea22d48c28..d3cc08677078 100644
--- a/openhands/storage/settings/file_settings_store.py
+++ b/openhands/storage/settings/file_settings_store.py
@@ -26,7 +26,7 @@ async def load(self) -> Settings | None:
             return None
 
     async def store(self, settings: Settings):
-        json_str = json.dumps(settings.__dict__)
+        json_str = settings.model_dump_json(context={'expose_secrets': True})
         await call_sync_from_async(self.file_store.write, self.path, json_str)
 
     @classmethod
diff --git a/openhands/utils/embeddings.py b/openhands/utils/embeddings.py
index 7e251f0e5022..6791787d3204 100644
--- a/openhands/utils/embeddings.py
+++ b/openhands/utils/embeddings.py
@@ -90,7 +90,9 @@ def get_embedding_model(strategy: str, llm_config: LLMConfig) -> 'BaseEmbedding'
 
             return OpenAIEmbedding(
                 model='text-embedding-ada-002',
-                api_key=llm_config.api_key,
+                api_key=llm_config.api_key.get_secret_value()
+                if llm_config.api_key
+                else None,
             )
         elif strategy == 'azureopenai':
             from llama_index.embeddings.azure_openai import AzureOpenAIEmbedding
diff --git a/tests/unit/test_acompletion.py b/tests/unit/test_acompletion.py
index b6753759be3d..cca18bbb5b29 100644
--- a/tests/unit/test_acompletion.py
+++ b/tests/unit/test_acompletion.py
@@ -109,9 +109,6 @@ async def mock_on_cancel_requested():
         print(f'Cancel requested: {is_set}')
         return is_set
 
-    config = load_app_config()
-    config.on_cancel_requested_fn = mock_on_cancel_requested
-
     async def mock_acompletion(*args, **kwargs):
         print('Starting mock_acompletion')
         for i in range(20):  # Increased iterations for longer running task
@@ -153,13 +150,6 @@ async def cancel_after_delay():
 async def test_async_streaming_completion_with_user_cancellation(cancel_after_chunks):
     cancel_requested = False
 
-    async def mock_on_cancel_requested():
-        nonlocal cancel_requested
-        return cancel_requested
-
-    config = load_app_config()
-    config.on_cancel_requested_fn = mock_on_cancel_requested
-
     test_messages = [
         'This is ',
         'a test ',
diff --git a/tests/unit/test_codeact_agent.py b/tests/unit/test_codeact_agent.py
index 84f0b8fc1993..26fa4428826e 100644
--- a/tests/unit/test_codeact_agent.py
+++ b/tests/unit/test_codeact_agent.py
@@ -60,7 +60,6 @@ def mock_state() -> State:
 
 
 def test_cmd_output_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = False
     obs = CmdOutputObservation(
         command='echo hello',
         content='Command output',
@@ -82,7 +81,6 @@ def test_cmd_output_observation_message(agent: CodeActAgent):
 
 
 def test_ipython_run_cell_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = False
     obs = IPythonRunCellObservation(
         code='plt.plot()',
         content='IPython output\n![image](data:image/png;base64,ABC123)',
@@ -105,7 +103,6 @@ def test_ipython_run_cell_observation_message(agent: CodeActAgent):
 
 
 def test_agent_delegate_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = False
     obs = AgentDelegateObservation(
         content='Content', outputs={'content': 'Delegated agent output'}
     )
@@ -122,7 +119,6 @@ def test_agent_delegate_observation_message(agent: CodeActAgent):
 
 
 def test_error_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = False
     obs = ErrorObservation('Error message')
 
     results = agent.get_observation_message(obs, tool_call_id_to_message={})
@@ -145,7 +141,6 @@ def test_unknown_observation_message(agent: CodeActAgent):
 
 
 def test_file_edit_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = False
     obs = FileEditObservation(
         path='/test/file.txt',
         prev_exist=True,
@@ -167,7 +162,6 @@ def test_file_edit_observation_message(agent: CodeActAgent):
 
 
 def test_file_read_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = False
     obs = FileReadObservation(
         path='/test/file.txt',
         content='File content',
@@ -186,7 +180,6 @@ def test_file_read_observation_message(agent: CodeActAgent):
 
 
 def test_browser_output_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = False
     obs = BrowserOutputObservation(
         url='http://example.com',
         trigger_by_action='browse',
@@ -207,7 +200,6 @@ def test_browser_output_observation_message(agent: CodeActAgent):
 
 
 def test_user_reject_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = False
     obs = UserRejectObservation('Action rejected')
 
     results = agent.get_observation_message(obs, tool_call_id_to_message={})
@@ -223,7 +215,6 @@ def test_user_reject_observation_message(agent: CodeActAgent):
 
 
 def test_function_calling_observation_message(agent: CodeActAgent):
-    agent.config.function_calling = True
     mock_response = {
         'id': 'mock_id',
         'total_calls_in_response': 1,
diff --git a/tests/unit/test_condenser.py b/tests/unit/test_condenser.py
index 4aa5afcf7543..91878c86baa1 100644
--- a/tests/unit/test_condenser.py
+++ b/tests/unit/test_condenser.py
@@ -226,7 +226,7 @@ def test_llm_condenser_from_config():
 
     assert isinstance(condenser, LLMSummarizingCondenser)
     assert condenser.llm.config.model == 'gpt-4o'
-    assert condenser.llm.config.api_key == 'test_key'
+    assert condenser.llm.config.api_key.get_secret_value() == 'test_key'
 
 
 def test_llm_condenser(mock_llm, mock_state):
@@ -381,7 +381,7 @@ def test_llm_attention_condenser_from_config():
 
     assert isinstance(condenser, LLMAttentionCondenser)
     assert condenser.llm.config.model == 'gpt-4o'
-    assert condenser.llm.config.api_key == 'test_key'
+    assert condenser.llm.config.api_key.get_secret_value() == 'test_key'
     assert condenser.max_size == 50
     assert condenser.keep_first == 10
 
diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py
index 44a76145cf6e..5edfd64cda90 100644
--- a/tests/unit/test_config.py
+++ b/tests/unit/test_config.py
@@ -63,7 +63,7 @@ def test_compat_env_to_config(monkeypatch, setup_env):
 
     assert config.workspace_base == '/repos/openhands/workspace'
     assert isinstance(config.get_llm_config(), LLMConfig)
-    assert config.get_llm_config().api_key == 'sk-proj-rgMV0...'
+    assert config.get_llm_config().api_key.get_secret_value() == 'sk-proj-rgMV0...'
     assert config.get_llm_config().model == 'gpt-4o'
     assert isinstance(config.get_agent_config(), AgentConfig)
     assert isinstance(config.get_agent_config().memory_max_threads, int)
@@ -83,7 +83,7 @@ def test_load_from_old_style_env(monkeypatch, default_config):
 
     load_from_env(default_config, os.environ)
 
-    assert default_config.get_llm_config().api_key == 'test-api-key'
+    assert default_config.get_llm_config().api_key.get_secret_value() == 'test-api-key'
     assert default_config.get_agent_config().memory_enabled is True
     assert default_config.default_agent == 'BrowsingAgent'
     assert default_config.workspace_base == '/opt/files/workspace'
@@ -126,7 +126,7 @@ def test_load_from_new_style_toml(default_config, temp_toml_file):
     # default llm & agent configs
     assert default_config.default_agent == 'TestAgent'
     assert default_config.get_llm_config().model == 'test-model'
-    assert default_config.get_llm_config().api_key == 'toml-api-key'
+    assert default_config.get_llm_config().api_key.get_secret_value() == 'toml-api-key'
     assert default_config.get_agent_config().memory_enabled is True
 
     # undefined agent config inherits default ones
@@ -291,7 +291,7 @@ def test_env_overrides_compat_toml(monkeypatch, default_config, temp_toml_file):
     assert default_config.get_llm_config().model == 'test-model'
     assert default_config.get_llm_config('llm').model == 'test-model'
     assert default_config.get_llm_config_from_agent().model == 'test-model'
-    assert default_config.get_llm_config().api_key == 'env-api-key'
+    assert default_config.get_llm_config().api_key.get_secret_value() == 'env-api-key'
 
     # after we set workspace_base to 'UNDEFINED' in the environment,
     # workspace_base should be set to that
@@ -336,7 +336,7 @@ def test_env_overrides_sandbox_toml(monkeypatch, default_config, temp_toml_file)
     assert default_config.workspace_mount_path is None
 
     # before load_from_env, values are set to the values from the toml file
-    assert default_config.get_llm_config().api_key == 'toml-api-key'
+    assert default_config.get_llm_config().api_key.get_secret_value() == 'toml-api-key'
     assert default_config.sandbox.timeout == 500
     assert default_config.sandbox.user_id == 1001
 
@@ -345,7 +345,7 @@ def test_env_overrides_sandbox_toml(monkeypatch, default_config, temp_toml_file)
     # values from env override values from toml
     assert os.environ.get('LLM_MODEL') is None
     assert default_config.get_llm_config().model == 'test-model'
-    assert default_config.get_llm_config().api_key == 'env-api-key'
+    assert default_config.get_llm_config().api_key.get_secret_value() == 'env-api-key'
 
     assert default_config.sandbox.timeout == 1000
     assert default_config.sandbox.user_id == 1002
@@ -412,7 +412,7 @@ def test_security_config_from_dict():
     # Test with all fields
     config_dict = {'confirmation_mode': True, 'security_analyzer': 'some_analyzer'}
 
-    security_config = SecurityConfig.from_dict(config_dict)
+    security_config = SecurityConfig(**config_dict)
 
     # Verify all fields are correctly set
     assert security_config.confirmation_mode is True
@@ -560,10 +560,7 @@ def test_load_from_toml_partial_invalid(default_config, temp_toml_file, caplog):
         assert 'Cannot parse [llm] config from toml' in log_content
         assert 'values have not been applied' in log_content
         # Error: LLMConfig.__init__() got an unexpected keyword argume
-        assert (
-            'Error: LLMConfig.__init__() got an unexpected keyword argume'
-            in log_content
-        )
+        assert 'Error: 1 validation error for LLMConfig' in log_content
         assert 'invalid_field' in log_content
 
         # invalid [sandbox] config
@@ -635,12 +632,14 @@ def test_api_keys_repr_str():
         aws_access_key_id='my_access_key',
         aws_secret_access_key='my_secret_key',
     )
-    assert "api_key='******'" in repr(llm_config)
-    assert "aws_access_key_id='******'" in repr(llm_config)
-    assert "aws_secret_access_key='******'" in repr(llm_config)
-    assert "api_key='******'" in str(llm_config)
-    assert "aws_access_key_id='******'" in str(llm_config)
-    assert "aws_secret_access_key='******'" in str(llm_config)
+
+    # Check that no secret keys are emitted in representations of the config object
+    assert 'my_api_key' not in repr(llm_config)
+    assert 'my_api_key' not in str(llm_config)
+    assert 'my_access_key' not in repr(llm_config)
+    assert 'my_access_key' not in str(llm_config)
+    assert 'my_secret_key' not in repr(llm_config)
+    assert 'my_secret_key' not in str(llm_config)
 
     # Check that no other attrs in LLMConfig have 'key' or 'token' in their name
     # This will fail when new attrs are added, and attract attention
@@ -652,7 +651,7 @@ def test_api_keys_repr_str():
         'output_cost_per_token',
         'custom_tokenizer',
     ]
-    for attr_name in dir(LLMConfig):
+    for attr_name in LLMConfig.model_fields.keys():
         if (
             not attr_name.startswith('__')
             and attr_name not in known_key_token_attrs_llm
@@ -667,7 +666,7 @@ def test_api_keys_repr_str():
     # Test AgentConfig
     # No attrs in AgentConfig have 'key' or 'token' in their name
     agent_config = AgentConfig(memory_enabled=True, memory_max_threads=4)
-    for attr_name in dir(AgentConfig):
+    for attr_name in AgentConfig.model_fields.keys():
         if not attr_name.startswith('__'):
             assert (
                 'key' not in attr_name.lower()
@@ -686,16 +685,16 @@ def test_api_keys_repr_str():
         modal_api_token_secret='my_modal_api_token_secret',
         runloop_api_key='my_runloop_api_key',
     )
-    assert "e2b_api_key='******'" in repr(app_config)
-    assert "e2b_api_key='******'" in str(app_config)
-    assert "jwt_secret='******'" in repr(app_config)
-    assert "jwt_secret='******'" in str(app_config)
-    assert "modal_api_token_id='******'" in repr(app_config)
-    assert "modal_api_token_id='******'" in str(app_config)
-    assert "modal_api_token_secret='******'" in repr(app_config)
-    assert "modal_api_token_secret='******'" in str(app_config)
-    assert "runloop_api_key='******'" in repr(app_config)
-    assert "runloop_api_key='******'" in str(app_config)
+    assert 'my_e2b_api_key' not in repr(app_config)
+    assert 'my_e2b_api_key' not in str(app_config)
+    assert 'my_jwt_secret' not in repr(app_config)
+    assert 'my_jwt_secret' not in str(app_config)
+    assert 'my_modal_api_token_id' not in repr(app_config)
+    assert 'my_modal_api_token_id' not in str(app_config)
+    assert 'my_modal_api_token_secret' not in repr(app_config)
+    assert 'my_modal_api_token_secret' not in str(app_config)
+    assert 'my_runloop_api_key' not in repr(app_config)
+    assert 'my_runloop_api_key' not in str(app_config)
 
     # Check that no other attrs in AppConfig have 'key' or 'token' in their name
     # This will fail when new attrs are added, and attract attention
@@ -705,7 +704,7 @@ def test_api_keys_repr_str():
         'modal_api_token_secret',
         'runloop_api_key',
     ]
-    for attr_name in dir(AppConfig):
+    for attr_name in AppConfig.model_fields.keys():
         if (
             not attr_name.startswith('__')
             and attr_name not in known_key_token_attrs_app
diff --git a/tests/unit/test_file_settings_store.py b/tests/unit/test_file_settings_store.py
index c20153c9fdce..347754035aa1 100644
--- a/tests/unit/test_file_settings_store.py
+++ b/tests/unit/test_file_settings_store.py
@@ -1,4 +1,3 @@
-import json
 from unittest.mock import MagicMock, patch
 
 import pytest
@@ -43,7 +42,7 @@ async def test_store_and_load_data(file_settings_store):
     await file_settings_store.store(init_data)
 
     # Verify store called with correct JSON
-    expected_json = json.dumps(init_data.__dict__)
+    expected_json = init_data.model_dump_json(context={'expose_secrets': True})
     file_settings_store.file_store.write.assert_called_once_with(
         'settings.json', expected_json
     )
@@ -60,7 +59,12 @@ async def test_store_and_load_data(file_settings_store):
     assert loaded_data.security_analyzer == init_data.security_analyzer
     assert loaded_data.confirmation_mode == init_data.confirmation_mode
     assert loaded_data.llm_model == init_data.llm_model
-    assert loaded_data.llm_api_key == init_data.llm_api_key
+    assert loaded_data.llm_api_key
+    assert init_data.llm_api_key
+    assert (
+        loaded_data.llm_api_key.get_secret_value()
+        == init_data.llm_api_key.get_secret_value()
+    )
     assert loaded_data.llm_base_url == init_data.llm_base_url
 
 
diff --git a/tests/unit/test_llm.py b/tests/unit/test_llm.py
index edf82d8aa41b..227b0006b020 100644
--- a/tests/unit/test_llm.py
+++ b/tests/unit/test_llm.py
@@ -40,7 +40,7 @@ def default_config():
 def test_llm_init_with_default_config(default_config):
     llm = LLM(default_config)
     assert llm.config.model == 'gpt-4o'
-    assert llm.config.api_key == 'test_key'
+    assert llm.config.api_key.get_secret_value() == 'test_key'
     assert isinstance(llm.metrics, Metrics)
     assert llm.metrics.model_name == 'gpt-4o'
 
@@ -77,7 +77,7 @@ def test_llm_init_with_custom_config():
     )
     llm = LLM(custom_config)
     assert llm.config.model == 'custom-model'
-    assert llm.config.api_key == 'custom_key'
+    assert llm.config.api_key.get_secret_value() == 'custom_key'
     assert llm.config.max_input_tokens == 5000
     assert llm.config.max_output_tokens == 1500
     assert llm.config.temperature == 0.8
diff --git a/tests/unit/test_llm_config.py b/tests/unit/test_llm_config.py
index 2fc22d6f2232..342112a44316 100644
--- a/tests/unit/test_llm_config.py
+++ b/tests/unit/test_llm_config.py
@@ -59,28 +59,28 @@ def test_load_from_toml_llm_with_fallback(
     # Verify generic LLM configuration
     generic_llm = default_config.get_llm_config('llm')
     assert generic_llm.model == 'base-model'
-    assert generic_llm.api_key == 'base-api-key'
+    assert generic_llm.api_key.get_secret_value() == 'base-api-key'
     assert generic_llm.embedding_model == 'base-embedding'
     assert generic_llm.num_retries == 3
 
     # Verify custom1 LLM falls back 'num_retries' from base
     custom1 = default_config.get_llm_config('custom1')
     assert custom1.model == 'custom-model-1'
-    assert custom1.api_key == 'custom-api-key-1'
+    assert custom1.api_key.get_secret_value() == 'custom-api-key-1'
     assert custom1.embedding_model == 'base-embedding'
     assert custom1.num_retries == 3  # from [llm]
 
     # Verify custom2 LLM overrides 'num_retries'
     custom2 = default_config.get_llm_config('custom2')
     assert custom2.model == 'custom-model-2'
-    assert custom2.api_key == 'custom-api-key-2'
+    assert custom2.api_key.get_secret_value() == 'custom-api-key-2'
     assert custom2.embedding_model == 'base-embedding'
     assert custom2.num_retries == 5  # overridden value
 
     # Verify custom3 LLM inherits all attributes except 'model' and 'api_key'
     custom3 = default_config.get_llm_config('custom3')
     assert custom3.model == 'custom-model-3'
-    assert custom3.api_key == 'custom-api-key-3'
+    assert custom3.api_key.get_secret_value() == 'custom-api-key-3'
     assert custom3.embedding_model == 'base-embedding'
     assert custom3.num_retries == 3  # from [llm]
 
@@ -113,14 +113,14 @@ def test_load_from_toml_llm_custom_overrides_all(
     # Verify generic LLM configuration remains unchanged
     generic_llm = default_config.get_llm_config('llm')
     assert generic_llm.model == 'base-model'
-    assert generic_llm.api_key == 'base-api-key'
+    assert generic_llm.api_key.get_secret_value() == 'base-api-key'
     assert generic_llm.embedding_model == 'base-embedding'
     assert generic_llm.num_retries == 3
 
     # Verify custom_full LLM overrides all attributes
     custom_full = default_config.get_llm_config('custom_full')
     assert custom_full.model == 'full-custom-model'
-    assert custom_full.api_key == 'full-custom-api-key'
+    assert custom_full.api_key.get_secret_value() == 'full-custom-api-key'
     assert custom_full.embedding_model == 'full-custom-embedding'
     assert custom_full.num_retries == 10  # overridden value
 
@@ -136,14 +136,14 @@ def test_load_from_toml_llm_custom_partial_override(
     # Verify custom1 LLM overrides 'model' and 'api_key' but inherits 'num_retries'
     custom1 = default_config.get_llm_config('custom1')
     assert custom1.model == 'custom-model-1'
-    assert custom1.api_key == 'custom-api-key-1'
+    assert custom1.api_key.get_secret_value() == 'custom-api-key-1'
     assert custom1.embedding_model == 'base-embedding'
     assert custom1.num_retries == 3  # from [llm]
 
     # Verify custom2 LLM overrides 'model', 'api_key', and 'num_retries'
     custom2 = default_config.get_llm_config('custom2')
     assert custom2.model == 'custom-model-2'
-    assert custom2.api_key == 'custom-api-key-2'
+    assert custom2.api_key.get_secret_value() == 'custom-api-key-2'
     assert custom2.embedding_model == 'base-embedding'
     assert custom2.num_retries == 5  # Overridden value
 
@@ -159,7 +159,7 @@ def test_load_from_toml_llm_custom_no_override(
     # Verify custom3 LLM inherits 'embedding_model' and 'num_retries' from generic
     custom3 = default_config.get_llm_config('custom3')
     assert custom3.model == 'custom-model-3'
-    assert custom3.api_key == 'custom-api-key-3'
+    assert custom3.api_key.get_secret_value() == 'custom-api-key-3'
     assert custom3.embedding_model == 'base-embedding'
     assert custom3.num_retries == 3  # from [llm]
 
@@ -186,7 +186,7 @@ def test_load_from_toml_llm_missing_generic(
     # Verify custom_only LLM uses its own attributes and defaults for others
     custom_only = default_config.get_llm_config('custom_only')
     assert custom_only.model == 'custom-only-model'
-    assert custom_only.api_key == 'custom-only-api-key'
+    assert custom_only.api_key.get_secret_value() == 'custom-only-api-key'
     assert custom_only.embedding_model == 'local'  # default value
     assert custom_only.num_retries == 8  # default value
 
@@ -217,12 +217,12 @@ def test_load_from_toml_llm_invalid_config(
     # Verify generic LLM is loaded correctly
     generic_llm = default_config.get_llm_config('llm')
     assert generic_llm.model == 'base-model'
-    assert generic_llm.api_key == 'base-api-key'
+    assert generic_llm.api_key.get_secret_value() == 'base-api-key'
     assert generic_llm.num_retries == 3
 
     # Verify invalid_custom LLM does not override generic attributes
     custom_invalid = default_config.get_llm_config('invalid_custom')
     assert custom_invalid.model == 'base-model'
-    assert custom_invalid.api_key == 'base-api-key'
+    assert custom_invalid.api_key.get_secret_value() == 'base-api-key'
     assert custom_invalid.num_retries == 3  # default value
     assert custom_invalid.embedding_model == 'local'  # default value
diff --git a/tests/unit/test_llm_draft_config.py b/tests/unit/test_llm_draft_config.py
index a9803782b2de..9a9b7dd6878d 100644
--- a/tests/unit/test_llm_draft_config.py
+++ b/tests/unit/test_llm_draft_config.py
@@ -86,7 +86,7 @@ def test_draft_editor_as_named_llm(config_toml_with_draft_editor):
     draft_llm = config.get_llm_config('draft_editor')
     assert draft_llm is not None
     assert draft_llm.model == 'draft-model'
-    assert draft_llm.api_key == 'draft-api-key'
+    assert draft_llm.api_key.get_secret_value() == 'draft-api-key'
 
 
 def test_draft_editor_fallback(config_toml_with_draft_editor):
diff --git a/tests/unit/test_settings_api.py b/tests/unit/test_settings_api.py
index a8e52a239010..ed7c02eb1403 100644
--- a/tests/unit/test_settings_api.py
+++ b/tests/unit/test_settings_api.py
@@ -2,6 +2,7 @@
 
 import pytest
 from fastapi.testclient import TestClient
+from pydantic import SecretStr
 
 from openhands.core.config.sandbox_config import SandboxConfig
 from openhands.server.app import app
@@ -50,7 +51,7 @@ async def test_settings_api_runtime_factor(test_client, mock_settings_store):
         'security_analyzer': 'default',
         'confirmation_mode': True,
         'llm_model': 'test-model',
-        'llm_api_key': None,
+        'llm_api_key': 'test-key',
         'llm_base_url': 'https://test.com',
         'remote_runtime_resource_factor': 2,
     }
@@ -83,3 +84,36 @@ async def test_settings_api_runtime_factor(test_client, mock_settings_store):
         mock_settings_store.store.assert_called()
         stored_settings = mock_settings_store.store.call_args[0][0]
         assert stored_settings.remote_runtime_resource_factor == 2
+
+        assert isinstance(stored_settings.llm_api_key, SecretStr)
+        assert stored_settings.llm_api_key.get_secret_value() == 'test-key'
+
+
+@pytest.mark.asyncio
+async def test_settings_llm_api_key(test_client, mock_settings_store):
+    # Mock the settings store to return None initially (no existing settings)
+    mock_settings_store.load.return_value = None
+
+    # Test data with remote_runtime_resource_factor
+    settings_data = {'llm_api_key': 'test-key'}
+
+    # The test_client fixture already handles authentication
+
+    # Make the POST request to store settings
+    response = test_client.post('/api/settings', json=settings_data)
+    assert response.status_code == 200
+
+    # Verify the settings were stored with the correct secret API key
+    stored_settings = mock_settings_store.store.call_args[0][0]
+    assert isinstance(stored_settings.llm_api_key, SecretStr)
+    assert stored_settings.llm_api_key.get_secret_value() == 'test-key'
+
+    # Mock settings store to return our settings for the GET request
+    mock_settings_store.load.return_value = Settings(**settings_data)
+
+    # Make a GET request to retrieve settings
+    response = test_client.get('/api/settings')
+    assert response.status_code == 200
+
+    # We should never expose the API key in the response
+    assert 'test-key' not in response.json()

From a1a87af69de9fe29bc134f98a3ceb24d99c0c114 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 17 Jan 2025 23:46:57 +0400
Subject: [PATCH 26/39] chore(deps): bump the version-all group across 1
 directory with 18 updates (#6332)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
---
 .../context-menu-list-item.test.tsx           |    5 +-
 .../modals/base-modal/base-modal.test.tsx     |   24 +-
 frontend/package-lock.json                    | 1441 ++++++++---------
 frontend/package.json                         |   36 +-
 frontend/vitest.setup.ts                      |    3 -
 5 files changed, 728 insertions(+), 781 deletions(-)

diff --git a/frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx b/frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx
index 55e19e099228..ffedba06e13d 100644
--- a/frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx
+++ b/frontend/__tests__/components/context-menu/context-menu-list-item.test.tsx
@@ -5,7 +5,10 @@ import { ContextMenuListItem } from "#/components/features/context-menu/context-
 
 describe("ContextMenuListItem", () => {
   it("should render the component with the children", () => {
-    render(<ContextMenuListItem onClick={vi.fn}>Test</ContextMenuListItem>);
+    const onClickMock = vi.fn();
+    render(
+      <ContextMenuListItem onClick={onClickMock}>Test</ContextMenuListItem>,
+    );
 
     expect(screen.getByTestId("context-menu-list-item")).toBeInTheDocument();
     expect(screen.getByText("Test")).toBeInTheDocument();
diff --git a/frontend/__tests__/components/modals/base-modal/base-modal.test.tsx b/frontend/__tests__/components/modals/base-modal/base-modal.test.tsx
index 0454de0c77ec..c3e5fdfa87c5 100644
--- a/frontend/__tests__/components/modals/base-modal/base-modal.test.tsx
+++ b/frontend/__tests__/components/modals/base-modal/base-modal.test.tsx
@@ -4,13 +4,21 @@ import { describe, it, vi, expect } from "vitest";
 import { BaseModal } from "#/components/shared/modals/base-modal/base-modal";
 
 describe("BaseModal", () => {
+  const onOpenChangeMock = vi.fn();
+
   it("should render if the modal is open", () => {
     const { rerender } = render(
-      <BaseModal isOpen={false} onOpenChange={vi.fn} title="Settings" />,
+      <BaseModal
+        isOpen={false}
+        onOpenChange={onOpenChangeMock}
+        title="Settings"
+      />,
     );
     expect(screen.queryByText("Settings")).not.toBeInTheDocument();
 
-    rerender(<BaseModal title="Settings" onOpenChange={vi.fn} isOpen />);
+    rerender(
+      <BaseModal title="Settings" onOpenChange={onOpenChangeMock} isOpen />,
+    );
     expect(screen.getByText("Settings")).toBeInTheDocument();
   });
 
@@ -18,7 +26,7 @@ describe("BaseModal", () => {
     render(
       <BaseModal
         isOpen
-        onOpenChange={vi.fn}
+        onOpenChange={onOpenChangeMock}
         title="Settings"
         subtitle="Subtitle"
       />,
@@ -43,7 +51,7 @@ describe("BaseModal", () => {
     render(
       <BaseModal
         isOpen
-        onOpenChange={vi.fn}
+        onOpenChange={onOpenChangeMock}
         title="Settings"
         actions={[primaryAction, secondaryAction]}
       />,
@@ -60,7 +68,6 @@ describe("BaseModal", () => {
   });
 
   it("should close the modal after an action is performed", async () => {
-    const onOpenChangeMock = vi.fn();
     render(
       <BaseModal
         isOpen
@@ -82,7 +89,7 @@ describe("BaseModal", () => {
 
   it("should render children", () => {
     render(
-      <BaseModal isOpen onOpenChange={vi.fn} title="Settings">
+      <BaseModal isOpen onOpenChange={onOpenChangeMock} title="Settings">
         <div>Children</div>
       </BaseModal>,
     );
@@ -93,7 +100,7 @@ describe("BaseModal", () => {
     const { rerender } = render(
       <BaseModal
         isOpen
-        onOpenChange={vi.fn}
+        onOpenChange={onOpenChangeMock}
         title="Settings"
         actions={[
           {
@@ -110,7 +117,7 @@ describe("BaseModal", () => {
     rerender(
       <BaseModal
         isOpen
-        onOpenChange={vi.fn}
+        onOpenChange={onOpenChangeMock}
         title="Settings"
         actions={[
           {
@@ -126,7 +133,6 @@ describe("BaseModal", () => {
   });
 
   it.skip("should not close if the backdrop or escape key is pressed", () => {
-    const onOpenChangeMock = vi.fn();
     render(
       <BaseModal
         isOpen
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d3c1caf6550a..48bb85cd4799 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,11 +10,11 @@
       "dependencies": {
         "@monaco-editor/react": "^4.7.0-rc.0",
         "@nextui-org/react": "^2.6.11",
-        "@react-router/node": "^7.1.1",
-        "@react-router/serve": "^7.1.1",
-        "@react-types/shared": "^3.25.0",
+        "@react-router/node": "^7.1.2",
+        "@react-router/serve": "^7.1.2",
+        "@react-types/shared": "^3.27.0",
         "@reduxjs/toolkit": "^2.5.0",
-        "@tanstack/react-query": "^5.63.0",
+        "@tanstack/react-query": "^5.64.1",
         "@vitejs/plugin-react": "^4.3.2",
         "@xterm/addon-fit": "^0.10.0",
         "@xterm/xterm": "^5.4.0",
@@ -27,7 +27,7 @@
         "isbot": "^5.1.21",
         "jose": "^5.9.4",
         "monaco-editor": "^0.52.2",
-        "posthog-js": "^1.205.0",
+        "posthog-js": "^1.207.0",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
         "react-highlight": "^0.15.0",
@@ -36,35 +36,35 @@
         "react-icons": "^5.4.0",
         "react-markdown": "^9.0.3",
         "react-redux": "^9.2.0",
-        "react-router": "^7.1.1",
+        "react-router": "^7.1.2",
         "react-syntax-highlighter": "^15.6.1",
         "react-textarea-autosize": "^8.5.7",
         "remark-gfm": "^4.0.0",
         "sirv-cli": "^3.0.0",
         "socket.io-client": "^4.8.1",
         "tailwind-merge": "^2.6.0",
-        "vite": "^5.4.11",
+        "vite": "^6.0.7",
         "web-vitals": "^3.5.2",
         "ws": "^8.18.0"
       },
       "devDependencies": {
         "@mswjs/socket.io-binding": "^0.1.1",
         "@playwright/test": "^1.49.1",
-        "@react-router/dev": "^7.1.1",
+        "@react-router/dev": "^7.1.2",
         "@tailwindcss/typography": "^0.5.16",
         "@tanstack/eslint-plugin-query": "^5.62.16",
         "@testing-library/jest-dom": "^6.6.1",
-        "@testing-library/react": "^16.1.0",
-        "@testing-library/user-event": "^14.5.2",
-        "@types/node": "^22.10.5",
-        "@types/react": "^19.0.4",
-        "@types/react-dom": "^19.0.2",
+        "@testing-library/react": "^16.2.0",
+        "@testing-library/user-event": "^14.6.0",
+        "@types/node": "^22.10.7",
+        "@types/react": "^19.0.7",
+        "@types/react-dom": "^19.0.3",
         "@types/react-highlight": "^0.12.8",
         "@types/react-syntax-highlighter": "^15.5.13",
         "@types/ws": "^8.5.12",
         "@typescript-eslint/eslint-plugin": "^7.18.0",
         "@typescript-eslint/parser": "^7.18.0",
-        "@vitest/coverage-v8": "^1.6.0",
+        "@vitest/coverage-v8": "^3.0.2",
         "autoprefixer": "^10.4.20",
         "cross-env": "^7.0.3",
         "eslint": "^8.57.0",
@@ -73,20 +73,20 @@
         "eslint-config-prettier": "^10.0.1",
         "eslint-plugin-import": "^2.29.1",
         "eslint-plugin-jsx-a11y": "^6.10.2",
-        "eslint-plugin-prettier": "^5.2.1",
+        "eslint-plugin-prettier": "^5.2.2",
         "eslint-plugin-react": "^7.37.4",
         "eslint-plugin-react-hooks": "^4.6.2",
         "husky": "^9.1.6",
         "jsdom": "^26.0.0",
-        "lint-staged": "^15.3.0",
+        "lint-staged": "^15.4.1",
         "msw": "^2.6.6",
-        "postcss": "^8.4.47",
+        "postcss": "^8.5.1",
         "prettier": "^3.4.2",
         "tailwindcss": "^3.4.17",
         "typescript": "^5.7.3",
         "vite-plugin-svgr": "^4.2.0",
         "vite-tsconfig-paths": "^5.1.4",
-        "vitest": "^1.6.0"
+        "vitest": "^3.0.2"
       },
       "engines": {
         "node": ">=20.0.0"
@@ -640,11 +640,13 @@
       }
     },
     "node_modules/@bcoe/v8-coverage": {
-      "version": "0.2.3",
-      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
-      "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+      "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
       "dev": true,
-      "license": "MIT"
+      "engines": {
+        "node": ">=18"
+      }
     },
     "node_modules/@bundled-es-modules/cookie": {
       "version": "2.0.1",
@@ -824,371 +826,378 @@
       }
     },
     "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
+      "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
       "cpu": [
         "ppc64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "aix"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/android-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
-      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
+      "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
       "cpu": [
         "arm"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "android"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/android-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
-      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
+      "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
       "cpu": [
         "arm64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "android"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/android-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
-      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
+      "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
       "cpu": [
         "x64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "android"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
-      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
+      "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
       "cpu": [
         "arm64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "darwin"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/darwin-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
-      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
+      "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
       "cpu": [
         "x64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "darwin"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
-      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
+      "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
       "cpu": [
         "arm64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "freebsd"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
-      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
+      "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
       "cpu": [
         "x64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "freebsd"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-arm": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
-      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
+      "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
       "cpu": [
         "arm"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
-      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
+      "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
       "cpu": [
         "arm64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
-      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
+      "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
       "cpu": [
         "ia32"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-loong64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
-      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
+      "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
       "cpu": [
         "loong64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
-      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
+      "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
       "cpu": [
         "mips64el"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
-      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
+      "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
       "cpu": [
         "ppc64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
-      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
+      "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
       "cpu": [
         "riscv64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-s390x": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
-      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
+      "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
       "cpu": [
         "s390x"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/linux-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
-      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
+      "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
       "cpu": [
         "x64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
+      "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
+      "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
       "cpu": [
         "x64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "netbsd"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
+      "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
+      "cpu": [
+        "arm64"
+      ],
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
-      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
+      "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
       "cpu": [
         "x64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "openbsd"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/sunos-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
-      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
+      "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
       "cpu": [
         "x64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "sunos"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/win32-arm64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
-      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
+      "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
       "cpu": [
         "arm64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/win32-ia32": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
-      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
+      "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
       "cpu": [
         "ia32"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@esbuild/win32-x64": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
-      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
+      "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
       "cpu": [
         "x64"
       ],
-      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ],
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       }
     },
     "node_modules/@eslint-community/eslint-utils": {
@@ -1634,24 +1643,10 @@
       "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
       "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
       "dev": true,
-      "license": "MIT",
       "engines": {
         "node": ">=8"
       }
     },
-    "node_modules/@jest/schemas": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
-      "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@sinclair/typebox": "^0.27.8"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
     "node_modules/@jridgewell/gen-mapping": {
       "version": "0.3.8",
       "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
@@ -1794,6 +1789,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/accordion/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/alert": {
       "version": "2.2.9",
       "resolved": "https://registry.npmjs.org/@nextui-org/alert/-/alert-2.2.9.tgz",
@@ -1832,6 +1835,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/aria-utils/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/autocomplete": {
       "version": "2.3.9",
       "resolved": "https://registry.npmjs.org/@nextui-org/autocomplete/-/autocomplete-2.3.9.tgz",
@@ -1868,6 +1879,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/autocomplete/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/avatar": {
       "version": "2.2.6",
       "resolved": "https://registry.npmjs.org/@nextui-org/avatar/-/avatar-2.2.6.tgz",
@@ -1923,6 +1942,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/breadcrumbs/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/button": {
       "version": "2.2.9",
       "resolved": "https://registry.npmjs.org/@nextui-org/button/-/button-2.2.9.tgz",
@@ -1948,6 +1975,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/button/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/calendar": {
       "version": "2.2.9",
       "resolved": "https://registry.npmjs.org/@nextui-org/calendar/-/calendar-2.2.9.tgz",
@@ -1983,6 +2018,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/calendar/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/card": {
       "version": "2.2.9",
       "resolved": "https://registry.npmjs.org/@nextui-org/card/-/card-2.2.9.tgz",
@@ -2006,6 +2049,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/card/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/checkbox": {
       "version": "2.3.8",
       "resolved": "https://registry.npmjs.org/@nextui-org/checkbox/-/checkbox-2.3.8.tgz",
@@ -2033,6 +2084,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/checkbox/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/chip": {
       "version": "2.2.6",
       "resolved": "https://registry.npmjs.org/@nextui-org/chip/-/chip-2.2.6.tgz",
@@ -2091,6 +2150,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/date-input/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/date-picker": {
       "version": "2.3.9",
       "resolved": "https://registry.npmjs.org/@nextui-org/date-picker/-/date-picker-2.3.9.tgz",
@@ -2123,6 +2190,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/date-picker/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/divider": {
       "version": "2.2.5",
       "resolved": "https://registry.npmjs.org/@nextui-org/divider/-/divider-2.2.5.tgz",
@@ -2139,6 +2214,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/divider/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/dom-animation": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/@nextui-org/dom-animation/-/dom-animation-2.1.1.tgz",
@@ -2209,6 +2292,14 @@
         "react-dom": ">=18"
       }
     },
+    "node_modules/@nextui-org/form/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/framer-utils": {
       "version": "2.1.6",
       "resolved": "https://registry.npmjs.org/@nextui-org/framer-utils/-/framer-utils-2.1.6.tgz",
@@ -2289,6 +2380,14 @@
         "react-dom": ">=18"
       }
     },
+    "node_modules/@nextui-org/input/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/kbd": {
       "version": "2.2.6",
       "resolved": "https://registry.npmjs.org/@nextui-org/kbd/-/kbd-2.2.6.tgz",
@@ -2352,6 +2451,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/listbox/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/menu": {
       "version": "2.2.9",
       "resolved": "https://registry.npmjs.org/@nextui-org/menu/-/menu-2.2.9.tgz",
@@ -2378,6 +2485,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/menu/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/modal": {
       "version": "2.2.7",
       "resolved": "https://registry.npmjs.org/@nextui-org/modal/-/modal-2.2.7.tgz",
@@ -2531,6 +2646,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/radio/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/react": {
       "version": "2.6.11",
       "resolved": "https://registry.npmjs.org/@nextui-org/react/-/react-2.6.11.tgz",
@@ -2677,6 +2800,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/select/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/shared-icons": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/@nextui-org/shared-icons/-/shared-icons-2.1.1.tgz",
@@ -2803,6 +2934,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/switch/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/system": {
       "version": "2.4.6",
       "resolved": "https://registry.npmjs.org/@nextui-org/system/-/system-2.4.6.tgz",
@@ -2836,6 +2975,14 @@
         "react": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/system-rsc/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/system-rsc/node_modules/clsx": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
@@ -2899,6 +3046,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/tabs/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/theme": {
       "version": "2.4.5",
       "resolved": "https://registry.npmjs.org/@nextui-org/theme/-/theme-2.4.5.tgz",
@@ -2969,6 +3124,14 @@
         "react": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/use-aria-accordion/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/use-aria-button": {
       "version": "2.2.4",
       "resolved": "https://registry.npmjs.org/@nextui-org/use-aria-button/-/use-aria-button-2.2.4.tgz",
@@ -2985,6 +3148,14 @@
         "react": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/use-aria-button/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/use-aria-link": {
       "version": "2.2.5",
       "resolved": "https://registry.npmjs.org/@nextui-org/use-aria-link/-/use-aria-link-2.2.5.tgz",
@@ -3001,6 +3172,14 @@
         "react": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/use-aria-link/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/use-aria-modal-overlay": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/@nextui-org/use-aria-modal-overlay/-/use-aria-modal-overlay-2.2.3.tgz",
@@ -3016,6 +3195,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/use-aria-modal-overlay/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/use-aria-multiselect": {
       "version": "2.4.3",
       "resolved": "https://registry.npmjs.org/@nextui-org/use-aria-multiselect/-/use-aria-multiselect-2.4.3.tgz",
@@ -3041,6 +3228,14 @@
         "react-dom": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/use-aria-multiselect/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/use-callback-ref": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/@nextui-org/use-callback-ref/-/use-callback-ref-2.1.1.tgz",
@@ -3121,6 +3316,14 @@
         "react": ">=18 || >=19.0.0-rc.0"
       }
     },
+    "node_modules/@nextui-org/use-intersection-observer/node_modules/@react-types/shared": {
+      "version": "3.26.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
+      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+      }
+    },
     "node_modules/@nextui-org/use-is-mobile": {
       "version": "2.2.2",
       "resolved": "https://registry.npmjs.org/@nextui-org/use-is-mobile/-/use-is-mobile-2.2.2.tgz",
@@ -3969,11 +4172,10 @@
       }
     },
     "node_modules/@react-router/dev": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.1.1.tgz",
-      "integrity": "sha512-+UCrQZBAmdRcC7Bx1ho89T/DeP+FzEErkzrTvdBCpstr8AzOQ6mKlaglXGty15o3fgihBSFF4/J67jGveYIR8Q==",
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.1.2.tgz",
+      "integrity": "sha512-iQ9t0SPEn8CopPOavVlThVG4GySqcQpsFyiyYJWtxzNCUY5wvhtEgSNIzIAD3o9Dv5X3IDfUQY6TvIzVrvFohw==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "@babel/core": "^7.21.8",
         "@babel/generator": "^7.21.5",
@@ -3984,7 +4186,7 @@
         "@babel/traverse": "^7.23.2",
         "@babel/types": "^7.22.5",
         "@npmcli/package-json": "^4.0.1",
-        "@react-router/node": "7.1.1",
+        "@react-router/node": "7.1.2",
         "arg": "^5.0.1",
         "babel-dead-code-elimination": "^1.0.6",
         "chokidar": "^4.0.0",
@@ -4012,8 +4214,8 @@
         "node": ">=20.0.0"
       },
       "peerDependencies": {
-        "@react-router/serve": "^7.1.1",
-        "react-router": "^7.1.1",
+        "@react-router/serve": "^7.1.2",
+        "react-router": "^7.1.2",
         "typescript": "^5.1.0",
         "vite": "^5.1.0 || ^6.0.0",
         "wrangler": "^3.28.2"
@@ -4046,33 +4248,10 @@
         "url": "https://github.com/prettier/prettier?sponsor=1"
       }
     },
-    "node_modules/@react-router/express": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.1.1.tgz",
-      "integrity": "sha512-oiL2ADor3byuh7piajLTPr6007GmVPZ1Gh4HiN0uuZlz3vQ1rd0xZMSD9LnSrXhsrKEbPFaeCk8E2O67ZoABsg==",
-      "license": "MIT",
-      "dependencies": {
-        "@react-router/node": "7.1.1"
-      },
-      "engines": {
-        "node": ">=20.0.0"
-      },
-      "peerDependencies": {
-        "express": "^4.17.1",
-        "react-router": "7.1.1",
-        "typescript": "^5.1.0"
-      },
-      "peerDependenciesMeta": {
-        "typescript": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/@react-router/node": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.1.1.tgz",
-      "integrity": "sha512-5X79SfJ1IEEsttt0oo9rhO9kgxXyBTKdVBsz3h0WHTkRzbRk0VEpVpBW3PQ1RpkgEaAHwJ8obVl4k4brdDSExA==",
-      "license": "MIT",
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.1.2.tgz",
+      "integrity": "sha512-PYLP0Vg0iE4w8iIHsusjMZx6h2PH2D4xSWQ550bgeW6Gj0JE6haB2HcaThvUyhBnnxZqNOPH335dvn+r76N5gQ==",
       "dependencies": {
         "@mjackson/node-fetch-server": "^0.2.0",
         "source-map-support": "^0.5.21",
@@ -4083,7 +4262,7 @@
         "node": ">=20.0.0"
       },
       "peerDependencies": {
-        "react-router": "7.1.1",
+        "react-router": "7.1.2",
         "typescript": "^5.1.0"
       },
       "peerDependenciesMeta": {
@@ -4093,13 +4272,12 @@
       }
     },
     "node_modules/@react-router/serve": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.1.1.tgz",
-      "integrity": "sha512-rhV1yp72ZZQn4giQUzUiLVo/7/7dhxD98Z5pdDm6mKOTJPGoQ8TBPccQaKxzJIFNRHcn0sEdehfLOxl5ydnUKw==",
-      "license": "MIT",
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.1.2.tgz",
+      "integrity": "sha512-cpmR/by4V9ZNAx0XgBFaJ0bhoDNUtAthHouhN65E85Yk2T4EKGOq2Oq08fZDvTUJWmb03HyTouN70XerezJ4cA==",
       "dependencies": {
-        "@react-router/express": "7.1.1",
-        "@react-router/node": "7.1.1",
+        "@react-router/express": "7.1.2",
+        "@react-router/node": "7.1.2",
         "compression": "^1.7.4",
         "express": "^4.19.2",
         "get-port": "5.1.1",
@@ -4113,7 +4291,28 @@
         "node": ">=20.0.0"
       },
       "peerDependencies": {
-        "react-router": "7.1.1"
+        "react-router": "7.1.2"
+      }
+    },
+    "node_modules/@react-router/serve/node_modules/@react-router/express": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.1.2.tgz",
+      "integrity": "sha512-bOPUfAmMEznd33itisilGAulFBgg0TL9gc9gTr6gcr+b7Kc9gSrKtHDaSSbetG4lVJf+wYgEMZMjqCkpDe87YA==",
+      "dependencies": {
+        "@react-router/node": "7.1.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "express": "^4.17.1",
+        "react-router": "7.1.2",
+        "typescript": "^5.1.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
       }
     },
     "node_modules/@react-stately/calendar": {
@@ -4625,10 +4824,9 @@
       }
     },
     "node_modules/@react-types/shared": {
-      "version": "3.26.0",
-      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.26.0.tgz",
-      "integrity": "sha512-6FuPqvhmjjlpEDLTiYx29IJCbCNWPlsyO+ZUmCUXzhUv2ttShOXfw8CmeHWHftT/b2KweAWuzqSlfeXPR76jpw==",
-      "license": "Apache-2.0",
+      "version": "3.27.0",
+      "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.27.0.tgz",
+      "integrity": "sha512-gvznmLhi6JPEf0bsq7SwRYTHAKKq/wcmKqFez9sRdbED+SPMUmK5omfZ6w3EwUFQHbYUa4zPBYedQ7Knv70RMw==",
       "peerDependencies": {
         "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
       }
@@ -5015,13 +5213,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/@sinclair/typebox": {
-      "version": "0.27.8",
-      "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
-      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/@socket.io/component-emitter": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
@@ -5293,20 +5484,20 @@
       }
     },
     "node_modules/@tanstack/query-core": {
-      "version": "5.62.16",
-      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.62.16.tgz",
-      "integrity": "sha512-9Sgft7Qavcd+sN0V25xVyo0nfmcZXBuODy3FVG7BMWTg1HMLm8wwG5tNlLlmSic1u7l1v786oavn+STiFaPH2g==",
+      "version": "5.64.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.64.1.tgz",
+      "integrity": "sha512-978Wx4Wl4UJZbmvU/rkaM9cQtXXrbhK0lsz/UZhYIbyKYA8E4LdomTwyh2GHZ4oU0BKKoDH4YlKk2VscCUgNmg==",
       "funding": {
         "type": "github",
         "url": "https://github.com/sponsors/tannerlinsley"
       }
     },
     "node_modules/@tanstack/react-query": {
-      "version": "5.63.0",
-      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.63.0.tgz",
-      "integrity": "sha512-QWizLzSiog8xqIRYmuJRok9VELlXVBAwtINgVCgW1SNvamQwWDO5R0XFSkjoBEj53x9Of1KAthLRBUC5xmtVLQ==",
+      "version": "5.64.1",
+      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.64.1.tgz",
+      "integrity": "sha512-vW5ggHpIO2Yjj44b4sB+Fd3cdnlMJppXRBJkEHvld6FXh3j5dwWJoQo7mGtKI2RbSFyiyu/PhGAy0+Vv5ev9Eg==",
       "dependencies": {
-        "@tanstack/query-core": "5.62.16"
+        "@tanstack/query-core": "5.64.1"
       },
       "funding": {
         "type": "github",
@@ -5405,11 +5596,10 @@
       "license": "MIT"
     },
     "node_modules/@testing-library/react": {
-      "version": "16.1.0",
-      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz",
-      "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==",
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.2.0.tgz",
+      "integrity": "sha512-2cSskAvA1QNtKc8Y9VJQRv0tm3hLVgxRGDB+KYhIaPQJ1I+RHbhIXcM+zClKXzMes/wshsMVzf4B9vS4IZpqDQ==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "@babel/runtime": "^7.12.5"
       },
@@ -5433,11 +5623,10 @@
       }
     },
     "node_modules/@testing-library/user-event": {
-      "version": "14.5.2",
-      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz",
-      "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==",
+      "version": "14.6.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.0.tgz",
+      "integrity": "sha512-+jsfK7kVJbqnCYtLTln8Ja/NmVrZRwBJHmHR9IxIVccMWSOZ6Oy0FkDJNeyVu4QSpMNmRfy10Xb76ObRDlWWBQ==",
       "dev": true,
-      "license": "MIT",
       "engines": {
         "node": ">=12",
         "npm": ">=6"
@@ -5570,28 +5759,27 @@
       "license": "MIT"
     },
     "node_modules/@types/node": {
-      "version": "22.10.5",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
-      "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==",
+      "version": "22.10.7",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
+      "integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
       "devOptional": true,
       "dependencies": {
         "undici-types": "~6.20.0"
       }
     },
     "node_modules/@types/react": {
-      "version": "19.0.4",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.4.tgz",
-      "integrity": "sha512-3O4QisJDYr1uTUMZHA2YswiQZRq+Pd8D+GdVFYikTutYsTz+QZgWkAPnP7rx9txoI6EXKcPiluMqWPFV3tT9Wg==",
+      "version": "19.0.7",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz",
+      "integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==",
       "dependencies": {
         "csstype": "^3.0.2"
       }
     },
     "node_modules/@types/react-dom": {
-      "version": "19.0.2",
-      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz",
-      "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==",
+      "version": "19.0.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.3.tgz",
+      "integrity": "sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==",
       "dev": true,
-      "license": "MIT",
       "peerDependencies": {
         "@types/react": "^19.0.0"
       }
@@ -6008,216 +6196,164 @@
       }
     },
     "node_modules/@vitest/coverage-v8": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz",
-      "integrity": "sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.2.tgz",
+      "integrity": "sha512-U+hZYb0FtgNDb6B3E9piAHzXXIuxuBw2cd6Lvepc9sYYY4KjgiwCBmo3Sird9ZRu3ggLpLBTfw1ZRr77ipiSfw==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "@ampproject/remapping": "^2.2.1",
-        "@bcoe/v8-coverage": "^0.2.3",
-        "debug": "^4.3.4",
+        "@ampproject/remapping": "^2.3.0",
+        "@bcoe/v8-coverage": "^1.0.2",
+        "debug": "^4.4.0",
         "istanbul-lib-coverage": "^3.2.2",
         "istanbul-lib-report": "^3.0.1",
-        "istanbul-lib-source-maps": "^5.0.4",
-        "istanbul-reports": "^3.1.6",
-        "magic-string": "^0.30.5",
-        "magicast": "^0.3.3",
-        "picocolors": "^1.0.0",
-        "std-env": "^3.5.0",
-        "strip-literal": "^2.0.0",
-        "test-exclude": "^6.0.0"
+        "istanbul-lib-source-maps": "^5.0.6",
+        "istanbul-reports": "^3.1.7",
+        "magic-string": "^0.30.17",
+        "magicast": "^0.3.5",
+        "std-env": "^3.8.0",
+        "test-exclude": "^7.0.1",
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
-        "vitest": "1.6.0"
+        "@vitest/browser": "3.0.2",
+        "vitest": "3.0.2"
+      },
+      "peerDependenciesMeta": {
+        "@vitest/browser": {
+          "optional": true
+        }
       }
     },
     "node_modules/@vitest/expect": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz",
-      "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.2.tgz",
+      "integrity": "sha512-dKSHLBcoZI+3pmP5hiZ7I5grNru2HRtEW8Z5Zp4IXog8QYcxhlox7JUPyIIFWfN53+3HW3KPLIl6nSzUGgKSuQ==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "@vitest/spy": "1.6.0",
-        "@vitest/utils": "1.6.0",
-        "chai": "^4.3.10"
+        "@vitest/spy": "3.0.2",
+        "@vitest/utils": "3.0.2",
+        "chai": "^5.1.2",
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/@vitest/runner": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz",
-      "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==",
+    "node_modules/@vitest/mocker": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.2.tgz",
+      "integrity": "sha512-Hr09FoBf0jlwwSyzIF4Xw31OntpO3XtZjkccpcBf8FeVW3tpiyKlkeUzxS/txzHqpUCNIX157NaTySxedyZLvA==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "@vitest/utils": "1.6.0",
-        "p-limit": "^5.0.0",
-        "pathe": "^1.1.1"
+        "@vitest/spy": "3.0.2",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.17"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
-      }
-    },
-    "node_modules/@vitest/runner/node_modules/p-limit": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
-      "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "yocto-queue": "^1.0.0"
       },
-      "engines": {
-        "node": ">=18"
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0 || ^6.0.0"
       },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
       }
     },
-    "node_modules/@vitest/runner/node_modules/yocto-queue": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz",
-      "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==",
+    "node_modules/@vitest/mocker/node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
       "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=12.20"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
+      "dependencies": {
+        "@types/estree": "^1.0.0"
       }
     },
-    "node_modules/@vitest/snapshot": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz",
-      "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==",
+    "node_modules/@vitest/pretty-format": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.2.tgz",
+      "integrity": "sha512-yBohcBw/T/p0/JRgYD+IYcjCmuHzjC3WLAKsVE4/LwiubzZkE8N49/xIQ/KGQwDRA8PaviF8IRO8JMWMngdVVQ==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "magic-string": "^0.30.5",
-        "pathe": "^1.1.1",
-        "pretty-format": "^29.7.0"
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/@vitest/snapshot/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+    "node_modules/@vitest/runner": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.2.tgz",
+      "integrity": "sha512-GHEsWoncrGxWuW8s405fVoDfSLk6RF2LCXp6XhevbtDjdDme1WV/eNmUueDfpY1IX3MJaCRelVCEXsT9cArfEg==",
       "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
+      "dependencies": {
+        "@vitest/utils": "3.0.2",
+        "pathe": "^2.0.1"
       },
       "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+        "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/@vitest/snapshot/node_modules/pretty-format": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-      "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+    "node_modules/@vitest/runner/node_modules/pathe": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz",
+      "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
+      "dev": true
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.2.tgz",
+      "integrity": "sha512-h9s67yD4+g+JoYG0zPCo/cLTabpDqzqNdzMawmNPzDStTiwxwkyYM1v5lWE8gmGv3SVJ2DcxA2NpQJZJv9ym3g==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "ansi-styles": "^5.0.0",
-        "react-is": "^18.0.0"
+        "@vitest/pretty-format": "3.0.2",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.1"
       },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+      "funding": {
+        "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/@vitest/snapshot/node_modules/react-is": {
-      "version": "18.3.1",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-      "dev": true,
-      "license": "MIT"
+    "node_modules/@vitest/snapshot/node_modules/pathe": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz",
+      "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
+      "dev": true
     },
     "node_modules/@vitest/spy": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz",
-      "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.2.tgz",
+      "integrity": "sha512-8mI2iUn+PJFMT44e3ISA1R+K6ALVs47W6eriDTfXe6lFqlflID05MB4+rIFhmDSLBj8iBsZkzBYlgSkinxLzSQ==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "tinyspy": "^2.2.0"
+        "tinyspy": "^3.0.2"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
     "node_modules/@vitest/utils": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz",
-      "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.2.tgz",
+      "integrity": "sha512-Qu01ZYZlgHvDP02JnMBRpX43nRaZtNpIzw3C1clDXmn8eakgX6iQVGzTQ/NjkIr64WD8ioqOjkaYRVvHQI5qiw==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "diff-sequences": "^29.6.3",
-        "estree-walker": "^3.0.3",
-        "loupe": "^2.3.7",
-        "pretty-format": "^29.7.0"
+        "@vitest/pretty-format": "3.0.2",
+        "loupe": "^3.1.2",
+        "tinyrainbow": "^2.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       }
     },
-    "node_modules/@vitest/utils/node_modules/ansi-styles": {
-      "version": "5.2.0",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
-      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
-      }
-    },
-    "node_modules/@vitest/utils/node_modules/estree-walker": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
-      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/estree": "^1.0.0"
-      }
-    },
-    "node_modules/@vitest/utils/node_modules/pretty-format": {
-      "version": "29.7.0",
-      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
-      "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@jest/schemas": "^29.6.3",
-        "ansi-styles": "^5.0.0",
-        "react-is": "^18.0.0"
-      },
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
-    "node_modules/@vitest/utils/node_modules/react-is": {
-      "version": "18.3.1",
-      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
-      "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/@xterm/addon-fit": {
       "version": "0.10.0",
       "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
@@ -6237,7 +6373,6 @@
       "version": "1.3.8",
       "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
       "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
-      "license": "MIT",
       "dependencies": {
         "mime-types": "~2.1.34",
         "negotiator": "0.6.3"
@@ -6250,7 +6385,6 @@
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
       "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -6278,19 +6412,6 @@
         "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
-    "node_modules/acorn-walk": {
-      "version": "8.3.4",
-      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
-      "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "acorn": "^8.11.0"
-      },
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
     "node_modules/agent-base": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
@@ -6420,8 +6541,7 @@
     "node_modules/array-flatten": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
-      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
-      "license": "MIT"
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
     },
     "node_modules/array-includes": {
       "version": "3.1.8",
@@ -6574,13 +6694,12 @@
       }
     },
     "node_modules/assertion-error": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
-      "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
       "dev": true,
-      "license": "MIT",
       "engines": {
-        "node": "*"
+        "node": ">=12"
       }
     },
     "node_modules/ast-types-flow": {
@@ -6744,7 +6863,6 @@
       "version": "1.20.3",
       "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
       "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
-      "license": "MIT",
       "dependencies": {
         "bytes": "3.1.2",
         "content-type": "~1.0.5",
@@ -6768,7 +6886,6 @@
       "version": "2.6.9",
       "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
       "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
-      "license": "MIT",
       "dependencies": {
         "ms": "2.0.0"
       }
@@ -6776,8 +6893,7 @@
     "node_modules/body-parser/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
-      "license": "MIT"
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/brace-expansion": {
       "version": "2.0.1",
@@ -6978,22 +7094,19 @@
       }
     },
     "node_modules/chai": {
-      "version": "4.5.0",
-      "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
-      "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz",
+      "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
-        "assertion-error": "^1.1.0",
-        "check-error": "^1.0.3",
-        "deep-eql": "^4.1.3",
-        "get-func-name": "^2.0.2",
-        "loupe": "^2.3.6",
-        "pathval": "^1.1.1",
-        "type-detect": "^4.1.0"
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
       },
       "engines": {
-        "node": ">=4"
+        "node": ">=12"
       }
     },
     "node_modules/chalk": {
@@ -7054,16 +7167,12 @@
       }
     },
     "node_modules/check-error": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
-      "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+      "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
       "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "get-func-name": "^2.0.2"
-      },
       "engines": {
-        "node": "*"
+        "node": ">= 16"
       }
     },
     "node_modules/chokidar": {
@@ -7393,13 +7502,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/confbox": {
-      "version": "0.1.8",
-      "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
-      "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/confusing-browser-globals": {
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
@@ -7420,7 +7522,6 @@
       "version": "0.5.4",
       "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
       "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
-      "license": "MIT",
       "dependencies": {
         "safe-buffer": "5.2.1"
       },
@@ -7432,7 +7533,6 @@
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
       "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -7447,7 +7547,6 @@
       "version": "0.7.1",
       "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
       "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -7455,8 +7554,7 @@
     "node_modules/cookie-signature": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
-      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
-      "license": "MIT"
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
     },
     "node_modules/core-js": {
       "version": "3.39.0",
@@ -7725,14 +7823,10 @@
       }
     },
     "node_modules/deep-eql": {
-      "version": "4.1.4",
-      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
-      "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
       "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "type-detect": "^4.0.0"
-      },
       "engines": {
         "node": ">=6"
       }
@@ -7819,7 +7913,6 @@
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
       "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.8",
         "npm": "1.2.8000 || >= 1.4.16"
@@ -7844,16 +7937,6 @@
       "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
       "license": "Apache-2.0"
     },
-    "node_modules/diff-sequences": {
-      "version": "29.6.3",
-      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
-      "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
-      }
-    },
     "node_modules/dir-glob": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -7960,7 +8043,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
       "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.8"
       }
@@ -8258,41 +8340,42 @@
       }
     },
     "node_modules/esbuild": {
-      "version": "0.21.5",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
-      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "version": "0.24.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
+      "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
       "hasInstallScript": true,
-      "license": "MIT",
       "bin": {
         "esbuild": "bin/esbuild"
       },
       "engines": {
-        "node": ">=12"
+        "node": ">=18"
       },
       "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.21.5",
-        "@esbuild/android-arm": "0.21.5",
-        "@esbuild/android-arm64": "0.21.5",
-        "@esbuild/android-x64": "0.21.5",
-        "@esbuild/darwin-arm64": "0.21.5",
-        "@esbuild/darwin-x64": "0.21.5",
-        "@esbuild/freebsd-arm64": "0.21.5",
-        "@esbuild/freebsd-x64": "0.21.5",
-        "@esbuild/linux-arm": "0.21.5",
-        "@esbuild/linux-arm64": "0.21.5",
-        "@esbuild/linux-ia32": "0.21.5",
-        "@esbuild/linux-loong64": "0.21.5",
-        "@esbuild/linux-mips64el": "0.21.5",
-        "@esbuild/linux-ppc64": "0.21.5",
-        "@esbuild/linux-riscv64": "0.21.5",
-        "@esbuild/linux-s390x": "0.21.5",
-        "@esbuild/linux-x64": "0.21.5",
-        "@esbuild/netbsd-x64": "0.21.5",
-        "@esbuild/openbsd-x64": "0.21.5",
-        "@esbuild/sunos-x64": "0.21.5",
-        "@esbuild/win32-arm64": "0.21.5",
-        "@esbuild/win32-ia32": "0.21.5",
-        "@esbuild/win32-x64": "0.21.5"
+        "@esbuild/aix-ppc64": "0.24.2",
+        "@esbuild/android-arm": "0.24.2",
+        "@esbuild/android-arm64": "0.24.2",
+        "@esbuild/android-x64": "0.24.2",
+        "@esbuild/darwin-arm64": "0.24.2",
+        "@esbuild/darwin-x64": "0.24.2",
+        "@esbuild/freebsd-arm64": "0.24.2",
+        "@esbuild/freebsd-x64": "0.24.2",
+        "@esbuild/linux-arm": "0.24.2",
+        "@esbuild/linux-arm64": "0.24.2",
+        "@esbuild/linux-ia32": "0.24.2",
+        "@esbuild/linux-loong64": "0.24.2",
+        "@esbuild/linux-mips64el": "0.24.2",
+        "@esbuild/linux-ppc64": "0.24.2",
+        "@esbuild/linux-riscv64": "0.24.2",
+        "@esbuild/linux-s390x": "0.24.2",
+        "@esbuild/linux-x64": "0.24.2",
+        "@esbuild/netbsd-arm64": "0.24.2",
+        "@esbuild/netbsd-x64": "0.24.2",
+        "@esbuild/openbsd-arm64": "0.24.2",
+        "@esbuild/openbsd-x64": "0.24.2",
+        "@esbuild/sunos-x64": "0.24.2",
+        "@esbuild/win32-arm64": "0.24.2",
+        "@esbuild/win32-ia32": "0.24.2",
+        "@esbuild/win32-x64": "0.24.2"
       }
     },
     "node_modules/escalade": {
@@ -8307,8 +8390,7 @@
     "node_modules/escape-html": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
-      "license": "MIT"
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
     },
     "node_modules/escape-string-regexp": {
       "version": "4.0.0",
@@ -8665,11 +8747,10 @@
       }
     },
     "node_modules/eslint-plugin-prettier": {
-      "version": "5.2.1",
-      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz",
-      "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==",
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.2.tgz",
+      "integrity": "sha512-1yI3/hf35wmlq66C8yOyrujQnel+v5l1Vop5Cl2I6ylyNTT1JbuUUnV3/41PzwTzcyDp/oF0jWE3HXvcH5AQOQ==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "prettier-linter-helpers": "^1.0.0",
         "synckit": "^0.9.1"
@@ -8973,7 +9054,6 @@
       "version": "1.8.1",
       "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
       "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -9022,11 +9102,19 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/expect-type": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz",
+      "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
     "node_modules/express": {
       "version": "4.21.2",
       "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
       "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
-      "license": "MIT",
       "dependencies": {
         "accepts": "~1.3.8",
         "array-flatten": "1.1.1",
@@ -9072,7 +9160,6 @@
       "version": "2.6.9",
       "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
       "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
-      "license": "MIT",
       "dependencies": {
         "ms": "2.0.0"
       }
@@ -9080,8 +9167,7 @@
     "node_modules/express/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
-      "license": "MIT"
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/extend": {
       "version": "3.0.2",
@@ -9202,7 +9288,6 @@
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
       "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
-      "license": "MIT",
       "dependencies": {
         "debug": "2.6.9",
         "encodeurl": "~2.0.0",
@@ -9220,7 +9305,6 @@
       "version": "2.6.9",
       "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
       "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
-      "license": "MIT",
       "dependencies": {
         "ms": "2.0.0"
       }
@@ -9228,8 +9312,7 @@
     "node_modules/finalhandler/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
-      "license": "MIT"
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/find-up": {
       "version": "5.0.0",
@@ -9350,7 +9433,6 @@
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
       "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -9400,7 +9482,6 @@
       "version": "0.5.2",
       "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
       "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -9513,16 +9594,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/get-func-name": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
-      "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "*"
-      }
-    },
     "node_modules/get-intrinsic": {
       "version": "1.2.6",
       "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
@@ -10034,7 +10105,6 @@
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
       "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
-      "license": "MIT",
       "dependencies": {
         "depd": "2.0.0",
         "inherits": "2.0.4",
@@ -10152,7 +10222,6 @@
       "version": "0.4.24",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
       "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
-      "license": "MIT",
       "dependencies": {
         "safer-buffer": ">= 2.1.2 < 3"
       },
@@ -10280,7 +10349,6 @@
       "version": "1.9.1",
       "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
       "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.10"
       }
@@ -11133,11 +11201,10 @@
       "license": "MIT"
     },
     "node_modules/lint-staged": {
-      "version": "15.3.0",
-      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.3.0.tgz",
-      "integrity": "sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==",
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.4.1.tgz",
+      "integrity": "sha512-P8yJuVRyLrm5KxCtFx+gjI5Bil+wO7wnTl7C3bXhvtTaAFGirzeB24++D0wGoUwxrUKecNiehemgCob9YL39NA==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "chalk": "~5.4.1",
         "commander": "~12.1.0",
@@ -11285,23 +11352,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/local-pkg": {
-      "version": "0.5.1",
-      "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
-      "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "mlly": "^1.7.3",
-        "pkg-types": "^1.2.1"
-      },
-      "engines": {
-        "node": ">=14"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/antfu"
-      }
-    },
     "node_modules/locate-path": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -11508,14 +11558,10 @@
       }
     },
     "node_modules/loupe": {
-      "version": "2.3.7",
-      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
-      "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "get-func-name": "^2.0.1"
-      }
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz",
+      "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==",
+      "dev": true
     },
     "node_modules/lower-case": {
       "version": "2.0.2",
@@ -11904,7 +11950,6 @@
       "version": "0.3.0",
       "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
       "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -11913,7 +11958,6 @@
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
       "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
-      "license": "MIT",
       "funding": {
         "url": "https://github.com/sponsors/sindresorhus"
       }
@@ -11938,7 +11982,6 @@
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
       "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -12523,7 +12566,6 @@
       "version": "1.6.0",
       "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
       "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
-      "license": "MIT",
       "bin": {
         "mime": "cli.js"
       },
@@ -12631,19 +12673,6 @@
         "node": ">=16 || 14 >=14.17"
       }
     },
-    "node_modules/mlly": {
-      "version": "1.7.3",
-      "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.3.tgz",
-      "integrity": "sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "acorn": "^8.14.0",
-        "pathe": "^1.1.2",
-        "pkg-types": "^1.2.1",
-        "ufo": "^1.5.4"
-      }
-    },
     "node_modules/monaco-editor": {
       "version": "0.52.2",
       "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz",
@@ -13157,7 +13186,6 @@
       "version": "2.4.1",
       "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
       "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
-      "license": "MIT",
       "dependencies": {
         "ee-first": "1.1.1"
       },
@@ -13369,7 +13397,6 @@
       "version": "1.3.3",
       "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
       "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.8"
       }
@@ -13434,8 +13461,7 @@
     "node_modules/path-to-regexp": {
       "version": "0.1.12",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
-      "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
-      "license": "MIT"
+      "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
     },
     "node_modules/path-type": {
       "version": "4.0.0",
@@ -13455,13 +13481,12 @@
       "license": "MIT"
     },
     "node_modules/pathval": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
-      "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
+      "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
       "dev": true,
-      "license": "MIT",
       "engines": {
-        "node": "*"
+        "node": ">= 14.16"
       }
     },
     "node_modules/peek-stream": {
@@ -13525,18 +13550,6 @@
         "node": ">= 6"
       }
     },
-    "node_modules/pkg-types": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.0.tgz",
-      "integrity": "sha512-kS7yWjVFCkIw9hqdJBoMxDdzEngmkr5FXeWZZfQ6GoYacjVnsW6l2CcYW/0ThD0vF4LPJgVYnrg4d0uuhwYQbg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "confbox": "^0.1.8",
-        "mlly": "^1.7.3",
-        "pathe": "^1.1.2"
-      }
-    },
     "node_modules/playwright": {
       "version": "1.49.1",
       "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
@@ -13580,9 +13593,9 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.4.49",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
-      "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
+      "version": "8.5.1",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
+      "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
       "funding": [
         {
           "type": "opencollective",
@@ -13597,9 +13610,8 @@
           "url": "https://github.com/sponsors/ai"
         }
       ],
-      "license": "MIT",
       "dependencies": {
-        "nanoid": "^3.3.7",
+        "nanoid": "^3.3.8",
         "picocolors": "^1.1.1",
         "source-map-js": "^1.2.1"
       },
@@ -13737,9 +13749,9 @@
       "license": "MIT"
     },
     "node_modules/posthog-js": {
-      "version": "1.205.0",
-      "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.205.0.tgz",
-      "integrity": "sha512-zP4SQ9Dg9JwqkEteoAOviAAAMdT/nJ4vk1jqfE6fVudziEa3szkQWd7czk5ehlEdrKFUE85MonCKW4L/uwtybA==",
+      "version": "1.207.0",
+      "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.207.0.tgz",
+      "integrity": "sha512-Sx+xamhg1/iKGAtUNh3uAUtAAza4j/yBhxcfUxfqR++WrZdw0V6nmh7LSfVNl7+QVl2qmiPSoZA7z+5ojaWDDQ==",
       "dependencies": {
         "core-js": "^3.38.1",
         "fflate": "^0.4.8",
@@ -13912,7 +13924,6 @@
       "version": "2.0.7",
       "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
       "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
-      "license": "MIT",
       "dependencies": {
         "forwarded": "0.2.0",
         "ipaddr.js": "1.9.1"
@@ -13977,7 +13988,6 @@
       "version": "6.13.0",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
       "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
-      "license": "BSD-3-Clause",
       "dependencies": {
         "side-channel": "^1.0.6"
       },
@@ -14019,7 +14029,6 @@
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
       "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.6"
       }
@@ -14028,7 +14037,6 @@
       "version": "2.5.2",
       "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
       "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
-      "license": "MIT",
       "dependencies": {
         "bytes": "3.1.2",
         "http-errors": "2.0.0",
@@ -14182,10 +14190,9 @@
       }
     },
     "node_modules/react-router": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.1.tgz",
-      "integrity": "sha512-39sXJkftkKWRZ2oJtHhCxmoCrBCULr/HAH4IT5DHlgu/Q0FCPV0S4Lx+abjDTx/74xoZzNYDYbOZWlJjruyuDQ==",
-      "license": "MIT",
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.2.tgz",
+      "integrity": "sha512-KeallSO30KLpIe/ZZqfk6pCJ1c+5JhMxl3SCS3Zx1LgaGuQbgLDmjuNi6KZ5LnAV9sWjbmBWGRw8Um/Pw6BExg==",
       "dependencies": {
         "@types/cookie": "^0.6.0",
         "cookie": "^1.0.1",
@@ -14955,7 +14962,6 @@
       "version": "0.19.0",
       "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
       "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
-      "license": "MIT",
       "dependencies": {
         "debug": "2.6.9",
         "depd": "2.0.0",
@@ -14979,7 +14985,6 @@
       "version": "2.6.9",
       "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
       "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
-      "license": "MIT",
       "dependencies": {
         "ms": "2.0.0"
       }
@@ -14987,14 +14992,12 @@
     "node_modules/send/node_modules/debug/node_modules/ms": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
-      "license": "MIT"
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
     },
     "node_modules/send/node_modules/encodeurl": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
       "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.8"
       }
@@ -15003,7 +15006,6 @@
       "version": "1.16.2",
       "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
       "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
-      "license": "MIT",
       "dependencies": {
         "encodeurl": "~2.0.0",
         "escape-html": "~1.0.3",
@@ -15057,8 +15059,7 @@
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
-      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
-      "license": "ISC"
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
     },
     "node_modules/shebang-command": {
       "version": "2.0.0",
@@ -15754,26 +15755,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/strip-literal": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
-      "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "js-tokens": "^9.0.1"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/antfu"
-      }
-    },
-    "node_modules/strip-literal/node_modules/js-tokens": {
-      "version": "9.0.1",
-      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
-      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/style-to-object": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz",
@@ -16003,64 +15984,17 @@
       }
     },
     "node_modules/test-exclude": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
-      "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
+      "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
       "dev": true,
-      "license": "ISC",
       "dependencies": {
         "@istanbuljs/schema": "^0.1.2",
-        "glob": "^7.1.4",
-        "minimatch": "^3.0.4"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/test-exclude/node_modules/brace-expansion": {
-      "version": "1.1.11",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
-      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "balanced-match": "^1.0.0",
-        "concat-map": "0.0.1"
-      }
-    },
-    "node_modules/test-exclude/node_modules/glob": {
-      "version": "7.2.3",
-      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
-      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
-      "deprecated": "Glob versions prior to v9 are no longer supported",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "fs.realpath": "^1.0.0",
-        "inflight": "^1.0.4",
-        "inherits": "2",
-        "minimatch": "^3.1.1",
-        "once": "^1.3.0",
-        "path-is-absolute": "^1.0.0"
-      },
-      "engines": {
-        "node": "*"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
-    "node_modules/test-exclude/node_modules/minimatch": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
-      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
-      "dev": true,
-      "license": "ISC",
-      "dependencies": {
-        "brace-expansion": "^1.1.7"
+        "glob": "^10.4.1",
+        "minimatch": "^9.0.4"
       },
       "engines": {
-        "node": "*"
+        "node": ">=18"
       }
     },
     "node_modules/text-table": {
@@ -16118,22 +16052,35 @@
         "node": ">=4"
       }
     },
+    "node_modules/tinyexec": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+      "dev": true
+    },
     "node_modules/tinypool": {
-      "version": "0.8.4",
-      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
-      "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
+      "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
+      "dev": true,
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+      "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
       "dev": true,
-      "license": "MIT",
       "engines": {
         "node": ">=14.0.0"
       }
     },
     "node_modules/tinyspy": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
-      "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+      "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
       "dev": true,
-      "license": "MIT",
       "engines": {
         "node": ">=14.0.0"
       }
@@ -16174,7 +16121,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
       "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
-      "license": "MIT",
       "engines": {
         "node": ">=0.6"
       }
@@ -16325,16 +16271,6 @@
         "node": ">= 0.8.0"
       }
     },
-    "node_modules/type-detect": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
-      "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/type-fest": {
       "version": "4.31.0",
       "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.31.0.tgz",
@@ -16352,7 +16288,6 @@
       "version": "1.6.18",
       "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
       "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
-      "license": "MIT",
       "dependencies": {
         "media-typer": "0.3.0",
         "mime-types": "~2.1.24"
@@ -16452,13 +16387,6 @@
         "node": ">=14.17"
       }
     },
-    "node_modules/ufo": {
-      "version": "1.5.4",
-      "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
-      "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/unbox-primitive": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -16595,7 +16523,6 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
       "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.8"
       }
@@ -16715,7 +16642,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
       "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
-      "license": "MIT",
       "engines": {
         "node": ">= 0.4.0"
       }
@@ -16794,20 +16720,19 @@
       }
     },
     "node_modules/vite": {
-      "version": "5.4.11",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
-      "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
-      "license": "MIT",
+      "version": "6.0.7",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz",
+      "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==",
       "dependencies": {
-        "esbuild": "^0.21.3",
-        "postcss": "^8.4.43",
-        "rollup": "^4.20.0"
+        "esbuild": "^0.24.2",
+        "postcss": "^8.4.49",
+        "rollup": "^4.23.0"
       },
       "bin": {
         "vite": "bin/vite.js"
       },
       "engines": {
-        "node": "^18.0.0 || >=20.0.0"
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
       },
       "funding": {
         "url": "https://github.com/vitejs/vite?sponsor=1"
@@ -16816,19 +16741,25 @@
         "fsevents": "~2.3.3"
       },
       "peerDependencies": {
-        "@types/node": "^18.0.0 || >=20.0.0",
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "jiti": ">=1.21.0",
         "less": "*",
         "lightningcss": "^1.21.0",
         "sass": "*",
         "sass-embedded": "*",
         "stylus": "*",
         "sugarss": "*",
-        "terser": "^5.4.0"
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
       },
       "peerDependenciesMeta": {
         "@types/node": {
           "optional": true
         },
+        "jiti": {
+          "optional": true
+        },
         "less": {
           "optional": true
         },
@@ -16849,6 +16780,12 @@
         },
         "terser": {
           "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
         }
       }
     },
@@ -16925,47 +16862,46 @@
       }
     },
     "node_modules/vitest": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz",
-      "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@vitest/expect": "1.6.0",
-        "@vitest/runner": "1.6.0",
-        "@vitest/snapshot": "1.6.0",
-        "@vitest/spy": "1.6.0",
-        "@vitest/utils": "1.6.0",
-        "acorn-walk": "^8.3.2",
-        "chai": "^4.3.10",
-        "debug": "^4.3.4",
-        "execa": "^8.0.1",
-        "local-pkg": "^0.5.0",
-        "magic-string": "^0.30.5",
-        "pathe": "^1.1.1",
-        "picocolors": "^1.0.0",
-        "std-env": "^3.5.0",
-        "strip-literal": "^2.0.0",
-        "tinybench": "^2.5.1",
-        "tinypool": "^0.8.3",
-        "vite": "^5.0.0",
-        "vite-node": "1.6.0",
-        "why-is-node-running": "^2.2.2"
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.2.tgz",
+      "integrity": "sha512-5bzaHakQ0hmVVKLhfh/jXf6oETDBtgPo8tQCHYB+wftNgFJ+Hah67IsWc8ivx4vFL025Ow8UiuTf4W57z4izvQ==",
+      "dev": true,
+      "dependencies": {
+        "@vitest/expect": "3.0.2",
+        "@vitest/mocker": "3.0.2",
+        "@vitest/pretty-format": "^3.0.2",
+        "@vitest/runner": "3.0.2",
+        "@vitest/snapshot": "3.0.2",
+        "@vitest/spy": "3.0.2",
+        "@vitest/utils": "3.0.2",
+        "chai": "^5.1.2",
+        "debug": "^4.4.0",
+        "expect-type": "^1.1.0",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.1",
+        "std-env": "^3.8.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.2",
+        "tinypool": "^1.0.2",
+        "tinyrainbow": "^2.0.0",
+        "vite": "^5.0.0 || ^6.0.0",
+        "vite-node": "3.0.2",
+        "why-is-node-running": "^2.3.0"
       },
       "bin": {
         "vitest": "vitest.mjs"
       },
       "engines": {
-        "node": "^18.0.0 || >=20.0.0"
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
       },
       "peerDependencies": {
         "@edge-runtime/vm": "*",
-        "@types/node": "^18.0.0 || >=20.0.0",
-        "@vitest/browser": "1.6.0",
-        "@vitest/ui": "1.6.0",
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "@vitest/browser": "3.0.2",
+        "@vitest/ui": "3.0.2",
         "happy-dom": "*",
         "jsdom": "*"
       },
@@ -16990,24 +16926,29 @@
         }
       }
     },
+    "node_modules/vitest/node_modules/pathe": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz",
+      "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
+      "dev": true
+    },
     "node_modules/vitest/node_modules/vite-node": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz",
-      "integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==",
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.2.tgz",
+      "integrity": "sha512-hsEQerBAHvVAbv40m3TFQe/lTEbOp7yDpyqMJqr2Tnd+W58+DEYOt+fluQgekOePcsNBmR77lpVAnIU2Xu4SvQ==",
       "dev": true,
-      "license": "MIT",
       "dependencies": {
         "cac": "^6.7.14",
-        "debug": "^4.3.4",
-        "pathe": "^1.1.1",
-        "picocolors": "^1.0.0",
-        "vite": "^5.0.0"
+        "debug": "^4.4.0",
+        "es-module-lexer": "^1.6.0",
+        "pathe": "^2.0.1",
+        "vite": "^5.0.0 || ^6.0.0"
       },
       "bin": {
         "vite-node": "vite-node.mjs"
       },
       "engines": {
-        "node": "^18.0.0 || >=20.0.0"
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
       },
       "funding": {
         "url": "https://opencollective.com/vitest"
diff --git a/frontend/package.json b/frontend/package.json
index dff2a8c17e57..14566f3be94b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -9,11 +9,11 @@
   "dependencies": {
     "@monaco-editor/react": "^4.7.0-rc.0",
     "@nextui-org/react": "^2.6.11",
-    "@react-router/node": "^7.1.1",
-    "@react-router/serve": "^7.1.1",
-    "@react-types/shared": "^3.25.0",
+    "@react-router/node": "^7.1.2",
+    "@react-router/serve": "^7.1.2",
+    "@react-types/shared": "^3.27.0",
     "@reduxjs/toolkit": "^2.5.0",
-    "@tanstack/react-query": "^5.63.0",
+    "@tanstack/react-query": "^5.64.1",
     "@vitejs/plugin-react": "^4.3.2",
     "@xterm/addon-fit": "^0.10.0",
     "@xterm/xterm": "^5.4.0",
@@ -26,7 +26,7 @@
     "isbot": "^5.1.21",
     "jose": "^5.9.4",
     "monaco-editor": "^0.52.2",
-    "posthog-js": "^1.205.0",
+    "posthog-js": "^1.207.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "react-highlight": "^0.15.0",
@@ -35,14 +35,14 @@
     "react-icons": "^5.4.0",
     "react-markdown": "^9.0.3",
     "react-redux": "^9.2.0",
-    "react-router": "^7.1.1",
+    "react-router": "^7.1.2",
     "react-syntax-highlighter": "^15.6.1",
     "react-textarea-autosize": "^8.5.7",
     "remark-gfm": "^4.0.0",
     "sirv-cli": "^3.0.0",
     "socket.io-client": "^4.8.1",
     "tailwind-merge": "^2.6.0",
-    "vite": "^5.4.11",
+    "vite": "^6.0.7",
     "web-vitals": "^3.5.2",
     "ws": "^8.18.0"
   },
@@ -77,21 +77,21 @@
   "devDependencies": {
     "@mswjs/socket.io-binding": "^0.1.1",
     "@playwright/test": "^1.49.1",
-    "@react-router/dev": "^7.1.1",
+    "@react-router/dev": "^7.1.2",
     "@tailwindcss/typography": "^0.5.16",
     "@tanstack/eslint-plugin-query": "^5.62.16",
     "@testing-library/jest-dom": "^6.6.1",
-    "@testing-library/react": "^16.1.0",
-    "@testing-library/user-event": "^14.5.2",
-    "@types/node": "^22.10.5",
-    "@types/react": "^19.0.4",
-    "@types/react-dom": "^19.0.2",
+    "@testing-library/react": "^16.2.0",
+    "@testing-library/user-event": "^14.6.0",
+    "@types/node": "^22.10.7",
+    "@types/react": "^19.0.7",
+    "@types/react-dom": "^19.0.3",
     "@types/react-highlight": "^0.12.8",
     "@types/react-syntax-highlighter": "^15.5.13",
     "@types/ws": "^8.5.12",
     "@typescript-eslint/eslint-plugin": "^7.18.0",
     "@typescript-eslint/parser": "^7.18.0",
-    "@vitest/coverage-v8": "^1.6.0",
+    "@vitest/coverage-v8": "^3.0.2",
     "autoprefixer": "^10.4.20",
     "cross-env": "^7.0.3",
     "eslint": "^8.57.0",
@@ -100,20 +100,20 @@
     "eslint-config-prettier": "^10.0.1",
     "eslint-plugin-import": "^2.29.1",
     "eslint-plugin-jsx-a11y": "^6.10.2",
-    "eslint-plugin-prettier": "^5.2.1",
+    "eslint-plugin-prettier": "^5.2.2",
     "eslint-plugin-react": "^7.37.4",
     "eslint-plugin-react-hooks": "^4.6.2",
     "husky": "^9.1.6",
     "jsdom": "^26.0.0",
-    "lint-staged": "^15.3.0",
+    "lint-staged": "^15.4.1",
     "msw": "^2.6.6",
-    "postcss": "^8.4.47",
+    "postcss": "^8.5.1",
     "prettier": "^3.4.2",
     "tailwindcss": "^3.4.17",
     "typescript": "^5.7.3",
     "vite-plugin-svgr": "^4.2.0",
     "vite-tsconfig-paths": "^5.1.4",
-    "vitest": "^1.6.0"
+    "vitest": "^3.0.2"
   },
   "packageManager": "npm@10.5.0",
   "volta": {
diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts
index e9a89c8677f6..221f1d10b6a9 100644
--- a/frontend/vitest.setup.ts
+++ b/frontend/vitest.setup.ts
@@ -3,10 +3,7 @@ import { cleanup } from "@testing-library/react";
 import { server } from "#/mocks/node";
 import "@testing-library/jest-dom/vitest";
 
-// @ts-expect-error - Mock for Terminal tests
 HTMLCanvasElement.prototype.getContext = vi.fn();
-
-// @ts-expect-error - handle TypeError: dom.scrollTo is not a function
 HTMLElement.prototype.scrollTo = vi.fn();
 
 // Mock the i18n provider

From 62fbe4c6223fef3cce311b7245f23bbd1cfc2c84 Mon Sep 17 00:00:00 2001
From: Graham Neubig <neubig@gmail.com>
Date: Fri, 17 Jan 2025 16:05:41 -0500
Subject: [PATCH 27/39] docs: improve custom sandbox guide with more
 configuration options (#5589)

Co-authored-by: openhands <openhands@all-hands.dev>
---
 .../usage/how-to/custom-sandbox-guide.md      | 32 +++++++++++++++++--
 1 file changed, 30 insertions(+), 2 deletions(-)

diff --git a/docs/modules/usage/how-to/custom-sandbox-guide.md b/docs/modules/usage/how-to/custom-sandbox-guide.md
index 154e1117ae66..a01d7c401b84 100644
--- a/docs/modules/usage/how-to/custom-sandbox-guide.md
+++ b/docs/modules/usage/how-to/custom-sandbox-guide.md
@@ -18,15 +18,21 @@ If you choose the first option, you can skip the `Create Your Docker Image` sect
 
 To create a custom Docker image, it must be Debian based.
 
-For example, if you want OpenHands to have `ruby` installed, create a `Dockerfile` with the following content:
+For example, if you want OpenHands to have `ruby` installed, you could create a `Dockerfile` with the following content:
 
 ```dockerfile
-FROM debian:latest
+FROM nikolaik/python-nodejs:python3.12-nodejs22
 
 # Install required packages
 RUN apt-get update && apt-get install -y ruby
 ```
 
+Or you could use a Ruby-specific base image:
+
+```dockerfile
+FROM ruby:latest
+```
+
 Save this file in a folder. Then, build your Docker image (e.g., named custom-image) by navigating to the folder in
 the terminal and running::
 ```bash
@@ -55,6 +61,28 @@ This can be an image you’ve already pulled or one you’ve built:
 sandbox_base_container_image="custom-image"
 ```
 
+### Additional Configuration Options
+
+The `config.toml` file supports several other options for customizing your sandbox:
+
+```toml
+[core]
+# Install additional dependencies when the runtime is built
+# Can contain any valid shell commands
+# If you need the path to the Python interpreter in any of these commands, you can use the $OH_INTERPRETER_PATH variable
+runtime_extra_deps = """
+pip install numpy pandas
+apt-get update && apt-get install -y ffmpeg
+"""
+
+# Set environment variables for the runtime
+# Useful for configuration that needs to be available at runtime
+runtime_startup_env_vars = { DATABASE_URL = "postgresql://user:pass@localhost/db" }
+
+# Specify platform for multi-architecture builds (e.g., "linux/amd64" or "linux/arm64")
+platform = "linux/amd64"
+```
+
 ### Run
 
 Run OpenHands by running ```make run``` in the top level directory.

From b1fa6301f01345e7c031ec2c26c3247b7719aa40 Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Fri, 17 Jan 2025 16:47:27 -0500
Subject: [PATCH 28/39] feat: add prompt for generating repo.md for an
 arbiratry repo (#6034)

Co-authored-by: Graham Neubig <neubig@gmail.com>
---
 .../tasks/add_openhands_repo_instruction.md   | 65 +++++++++++++++++++
 1 file changed, 65 insertions(+)
 create mode 100644 microagents/tasks/add_openhands_repo_instruction.md

diff --git a/microagents/tasks/add_openhands_repo_instruction.md b/microagents/tasks/add_openhands_repo_instruction.md
new file mode 100644
index 000000000000..ef2a89ccdffb
--- /dev/null
+++ b/microagents/tasks/add_openhands_repo_instruction.md
@@ -0,0 +1,65 @@
+---
+name: add_openhands_repo_instruction
+type: task
+version: 1.0.0
+author: openhands
+agent: CodeActAgent
+inputs:
+  - name: REPO_FOLDER_NAME
+    description: "Branch for the agent to work on"
+    required: false
+---
+
+Please browse the current repository under /workspace/{{ REPO_FOLDER_NAME }}, look at the documentation and relevant code, and understand the purpose of this repository.
+
+Specifically, I want you to create a `.openhands/microagents/repo.md`  file. This file should contain succinct information that summarizes (1) the purpose of this repository, (2) the general setup of this repo, and (3) a brief description of the structure of this repo.
+
+Here's an example:
+```markdown
+---
+name: repo
+type: repo
+agent: CodeActAgent
+---
+
+This repository contains the code for runtime-API, an automated AI software engineer. It has a Python backend
+(in the `openhands` directory) and React frontend (in the `frontend` directory).
+
+## General Setup:
+To set up the entire repo, including frontend and backend, run `make build`.
+You don't need to do this unless the user asks you to, or if you're trying to run the entire application.
+
+Before pushing any changes, you should ensure that any lint errors or simple test errors have been fixed.
+
+* If you've made changes to the backend, you should run `pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml`
+* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
+
+If either command fails, it may have automatically fixed some issues. You should fix any issues that weren't automatically fixed,
+then re-run the command to ensure it passes.
+
+## Repository Structure
+Backend:
+- Located in the `openhands` directory
+- Testing:
+  - All tests are in `tests/unit/test_*.py`
+  - To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
+  - Write all tests with pytest
+
+Frontend:
+- Located in the `frontend` directory
+- Prerequisites: A recent version of NodeJS / NPM
+- Setup: Run `npm install` in the frontend directory
+- Testing:
+  - Run tests: `npm run test`
+  - To run specific tests: `npm run test -- -t "TestName"`
+- Building:
+  - Build for production: `npm run build`
+- Environment Variables:
+  - Set in `frontend/.env` or as environment variables
+  - Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
+- Internationalization:
+  - Generate i18n declaration file: `npm run make-i18n`
+```
+
+Now, please write a similar markdown for the current repository.
+Read all the GitHub workflows under .github/ of the repository (if this folder exists) to understand the CI checks (e.g., linter, pre-commit), and include those in the repo.md file.

From f07ec7a09c73efb3d6bb99621e304d6437509a44 Mon Sep 17 00:00:00 2001
From: Calvin Smith <email@cjsmith.io>
Date: Fri, 17 Jan 2025 15:16:57 -0700
Subject: [PATCH 29/39] fix: Conversation creation accessing secret without
 unwrapping (#6335)

Co-authored-by: Calvin Smith <calvin@all-hands.dev>
---
 openhands/runtime/impl/remote/remote_runtime.py | 12 ++++++------
 openhands/server/routes/manage_conversations.py |  5 ++++-
 2 files changed, 10 insertions(+), 7 deletions(-)

diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py
index 41e1620e295f..54947720204f 100644
--- a/openhands/runtime/impl/remote/remote_runtime.py
+++ b/openhands/runtime/impl/remote/remote_runtime.py
@@ -60,12 +60,6 @@ def __init__(
             )
         self.session.headers.update({'X-API-Key': self.config.sandbox.api_key})
 
-        if self.config.workspace_base is not None:
-            self.log(
-                'debug',
-                'Setting workspace_base is not supported in the remote runtime.',
-            )
-
         self.runtime_builder = RemoteRuntimeBuilder(
             self.config.sandbox.remote_runtime_api_url,
             self.config.sandbox.api_key,
@@ -76,6 +70,12 @@ def __init__(
         self.available_hosts: dict[str, int] = {}
         self._runtime_initialized: bool = False
 
+        if self.config.workspace_base is not None:
+            self.log(
+                'debug',
+                'Setting workspace_base is not supported in the remote runtime.',
+            )
+
     def log(self, level: str, message: str) -> None:
         message = f'[runtime session_id={self.sid} runtime_id={self.runtime_id or "unknown"}] {message}'
         getattr(logger, level)(message, stacklevel=2)
diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py
index 767c52b706b3..3d8c0fe1523a 100644
--- a/openhands/server/routes/manage_conversations.py
+++ b/openhands/server/routes/manage_conversations.py
@@ -51,7 +51,10 @@ async def _create_new_conversation(
         session_init_args = {**settings.__dict__, **session_init_args}
         # We could use litellm.check_valid_key for a more accurate check,
         # but that would run a tiny inference.
-        if not settings.llm_api_key or settings.llm_api_key.isspace():
+        if (
+            not settings.llm_api_key
+            or settings.llm_api_key.get_secret_value().isspace()
+        ):
             logger.warn(f'Missing api key for model {settings.llm_model}')
             raise LLMAuthenticationError(
                 'Error authenticating with the LLM provider. Please check your API key'

From 987861b5e7ade2a40694a7a30781c72d4bc86829 Mon Sep 17 00:00:00 2001
From: mamoodi <mamoodiha@gmail.com>
Date: Fri, 17 Jan 2025 17:41:31 -0500
Subject: [PATCH 30/39] Remove broken browser counter logic (#6334)

Co-authored-by: openhands <openhands@all-hands.dev>
---
 frontend/__tests__/components/browser.test.tsx | 2 --
 frontend/src/routes/_oh.app/route.tsx          | 6 +-----
 frontend/src/state/browser-slice.ts            | 3 ---
 openhands/server/settings.py                   | 2 +-
 4 files changed, 2 insertions(+), 11 deletions(-)

diff --git a/frontend/__tests__/components/browser.test.tsx b/frontend/__tests__/components/browser.test.tsx
index f375e2dd50f9..a3056df1d3b3 100644
--- a/frontend/__tests__/components/browser.test.tsx
+++ b/frontend/__tests__/components/browser.test.tsx
@@ -37,7 +37,6 @@ describe("Browser", () => {
         browser: {
           url: "https://example.com",
           screenshotSrc: "",
-          updateCount: 0,
         },
       },
     });
@@ -53,7 +52,6 @@ describe("Browser", () => {
           url: "https://example.com",
           screenshotSrc:
             "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==",
-          updateCount: 0,
         },
       },
     });
diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx
index aed4bdde748e..ac846567e0a9 100644
--- a/frontend/src/routes/_oh.app/route.tsx
+++ b/frontend/src/routes/_oh.app/route.tsx
@@ -1,7 +1,7 @@
 import { useDisclosure } from "@nextui-org/react";
 import React from "react";
 import { Outlet } from "react-router";
-import { useDispatch, useSelector } from "react-redux";
+import { useDispatch } from "react-redux";
 import { FaServer } from "react-icons/fa";
 import toast from "react-hot-toast";
 import { useTranslation } from "react-i18next";
@@ -11,7 +11,6 @@ import {
   useConversation,
 } from "#/context/conversation-context";
 import { Controls } from "#/components/features/controls/controls";
-import { RootState } from "#/store";
 import { clearMessages } from "#/state/chat-slice";
 import { clearTerminal } from "#/state/command-slice";
 import { useEffectOnce } from "#/hooks/use-effect-once";
@@ -33,7 +32,6 @@ import {
 import Security from "#/components/shared/modals/security/security";
 import { useEndSession } from "#/hooks/use-end-session";
 import { useUserConversation } from "#/hooks/query/use-user-conversation";
-import { CountBadge } from "#/components/layout/count-badge";
 import { ServedAppLabel } from "#/components/layout/served-app-label";
 import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
 import { useSettings } from "#/hooks/query/use-settings";
@@ -52,7 +50,6 @@ function AppContent() {
   const endSession = useEndSession();
 
   const [width, setWidth] = React.useState(window.innerWidth);
-  const { updateCount } = useSelector((state: RootState) => state.browser);
 
   const secrets = React.useMemo(
     () => [gitHubToken].filter((secret) => secret !== null),
@@ -144,7 +141,6 @@ function AppContent() {
                     label: (
                       <div className="flex items-center gap-1">
                         {t(I18nKey.BROWSER$TITLE)}
-                        {updateCount > 0 && <CountBadge count={updateCount} />}
                       </div>
                     ),
                     to: "browser",
diff --git a/frontend/src/state/browser-slice.ts b/frontend/src/state/browser-slice.ts
index 7276f3577a3f..fc05f0c50830 100644
--- a/frontend/src/state/browser-slice.ts
+++ b/frontend/src/state/browser-slice.ts
@@ -5,8 +5,6 @@ export const initialState = {
   url: "https://github.com/All-Hands-AI/OpenHands",
   // Base64-encoded screenshot of browser window (placeholder for now, will be replaced with the actual screenshot later)
   screenshotSrc: "",
-  // Counter for browser updates
-  updateCount: 0,
 };
 
 export const browserSlice = createSlice({
@@ -18,7 +16,6 @@ export const browserSlice = createSlice({
     },
     setScreenshotSrc: (state, action) => {
       state.screenshotSrc = action.payload;
-      state.updateCount += 1;
     },
   },
 });
diff --git a/openhands/server/settings.py b/openhands/server/settings.py
index bdb755fd5c20..83d89e20b1af 100644
--- a/openhands/server/settings.py
+++ b/openhands/server/settings.py
@@ -21,7 +21,7 @@ class Settings(BaseModel):
     def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo):
         """Custom serializer for the LLM API key.
 
-        To serialize the API key instead of `"********"`, set `expose_secrets` to True in the serialization context. For example::
+        To serialize the API key instead of ********, set expose_secrets to True in the serialization context. For example:
 
         settings.model_dump_json(context={'expose_secrets': True})
         """

From 532c7cdf02e747238358c45cdea1d9c24cf6bde5 Mon Sep 17 00:00:00 2001
From: mamoodi <mamoodiha@gmail.com>
Date: Fri, 17 Jan 2025 19:16:47 -0500
Subject: [PATCH 31/39] Attempt to fix doc deploy (#6337)

---
 openhands/server/settings.py | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/openhands/server/settings.py b/openhands/server/settings.py
index 83d89e20b1af..6732ea2445d4 100644
--- a/openhands/server/settings.py
+++ b/openhands/server/settings.py
@@ -21,9 +21,7 @@ class Settings(BaseModel):
     def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo):
         """Custom serializer for the LLM API key.
 
-        To serialize the API key instead of ********, set expose_secrets to True in the serialization context. For example:
-
-        settings.model_dump_json(context={'expose_secrets': True})
+        To serialize the API key instead of ********, set expose_secrets to True in the serialization context.
         """
         context = info.context
         if context and context.get('expose_secrets', False):

From b4d20e3e1889f2104f41de8c9f9f81dbd3018333 Mon Sep 17 00:00:00 2001
From: tofarr <tofarr@gmail.com>
Date: Fri, 17 Jan 2025 20:17:18 -0700
Subject: [PATCH 32/39] Feat: settings default (#6328)

Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
---
 openhands/server/settings.py                  | 26 +++++++
 .../storage/settings/file_settings_store.py   |  2 +-
 tests/unit/test_file_settings_store.py        |  8 ++-
 tests/unit/test_settings.py                   | 67 +++++++++++++++++++
 4 files changed, 100 insertions(+), 3 deletions(-)
 create mode 100644 tests/unit/test_settings.py

diff --git a/openhands/server/settings.py b/openhands/server/settings.py
index 6732ea2445d4..19174706e497 100644
--- a/openhands/server/settings.py
+++ b/openhands/server/settings.py
@@ -1,6 +1,11 @@
+from __future__ import annotations
+
 from pydantic import BaseModel, SecretStr, SerializationInfo, field_serializer
 from pydantic.json import pydantic_encoder
 
+from openhands.core.config.llm_config import LLMConfig
+from openhands.core.config.utils import load_app_config
+
 
 class Settings(BaseModel):
     """
@@ -28,3 +33,24 @@ def llm_api_key_serializer(self, llm_api_key: SecretStr, info: SerializationInfo
             return llm_api_key.get_secret_value()
 
         return pydantic_encoder(llm_api_key)
+
+    @staticmethod
+    def from_config() -> Settings | None:
+        app_config = load_app_config()
+        llm_config: LLMConfig = app_config.get_llm_config()
+        if llm_config.api_key is None:
+            # If no api key has been set, we take this to mean that there is no reasonable default
+            return None
+        security = app_config.security
+        settings = Settings(
+            language='en',
+            agent=app_config.default_agent,
+            max_iterations=app_config.max_iterations,
+            security_analyzer=security.security_analyzer,
+            confirmation_mode=security.confirmation_mode,
+            llm_model=llm_config.model,
+            llm_api_key=llm_config.api_key,
+            llm_base_url=llm_config.base_url,
+            remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor,
+        )
+        return settings
diff --git a/openhands/storage/settings/file_settings_store.py b/openhands/storage/settings/file_settings_store.py
index d3cc08677078..eaf35554d7ae 100644
--- a/openhands/storage/settings/file_settings_store.py
+++ b/openhands/storage/settings/file_settings_store.py
@@ -23,7 +23,7 @@ async def load(self) -> Settings | None:
             settings = Settings(**kwargs)
             return settings
         except FileNotFoundError:
-            return None
+            return Settings.from_config()
 
     async def store(self, settings: Settings):
         json_str = settings.model_dump_json(context={'expose_secrets': True})
diff --git a/tests/unit/test_file_settings_store.py b/tests/unit/test_file_settings_store.py
index 347754035aa1..4dd0e1d360a7 100644
--- a/tests/unit/test_file_settings_store.py
+++ b/tests/unit/test_file_settings_store.py
@@ -20,8 +20,12 @@ def file_settings_store(mock_file_store):
 
 @pytest.mark.asyncio
 async def test_load_nonexistent_data(file_settings_store):
-    file_settings_store.file_store.read.side_effect = FileNotFoundError()
-    assert await file_settings_store.load() is None
+    with patch(
+        'openhands.server.settings.load_app_config',
+        MagicMock(return_value=AppConfig()),
+    ):
+        file_settings_store.file_store.read.side_effect = FileNotFoundError()
+        assert await file_settings_store.load() is None
 
 
 @pytest.mark.asyncio
diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py
new file mode 100644
index 000000000000..a785a75d08f1
--- /dev/null
+++ b/tests/unit/test_settings.py
@@ -0,0 +1,67 @@
+from unittest.mock import patch
+
+from pydantic import SecretStr
+
+from openhands.core.config.app_config import AppConfig
+from openhands.core.config.llm_config import LLMConfig
+from openhands.core.config.sandbox_config import SandboxConfig
+from openhands.core.config.security_config import SecurityConfig
+from openhands.server.settings import Settings
+
+
+def test_settings_from_config():
+    # Mock configuration
+    mock_app_config = AppConfig(
+        default_agent='test-agent',
+        max_iterations=100,
+        security=SecurityConfig(
+            security_analyzer='test-analyzer', confirmation_mode=True
+        ),
+        llms={
+            'llm': LLMConfig(
+                model='test-model',
+                api_key=SecretStr('test-key'),
+                base_url='https://test.example.com',
+            )
+        },
+        sandbox=SandboxConfig(remote_runtime_resource_factor=2),
+    )
+
+    with patch(
+        'openhands.server.settings.load_app_config', return_value=mock_app_config
+    ):
+        settings = Settings.from_config()
+
+        assert settings is not None
+        assert settings.language == 'en'
+        assert settings.agent == 'test-agent'
+        assert settings.max_iterations == 100
+        assert settings.security_analyzer == 'test-analyzer'
+        assert settings.confirmation_mode is True
+        assert settings.llm_model == 'test-model'
+        assert settings.llm_api_key.get_secret_value() == 'test-key'
+        assert settings.llm_base_url == 'https://test.example.com'
+        assert settings.remote_runtime_resource_factor == 2
+
+
+def test_settings_from_config_no_api_key():
+    # Mock configuration without API key
+    mock_app_config = AppConfig(
+        default_agent='test-agent',
+        max_iterations=100,
+        security=SecurityConfig(
+            security_analyzer='test-analyzer', confirmation_mode=True
+        ),
+        llms={
+            'llm': LLMConfig(
+                model='test-model', api_key=None, base_url='https://test.example.com'
+            )
+        },
+        sandbox=SandboxConfig(remote_runtime_resource_factor=2),
+    )
+
+    with patch(
+        'openhands.server.settings.load_app_config', return_value=mock_app_config
+    ):
+        settings = Settings.from_config()
+        assert settings is None

From 4383be1ab464296da2076ec7a21f144b7d63773a Mon Sep 17 00:00:00 2001
From: Boxuan Li <liboxuan@connect.hku.hk>
Date: Fri, 17 Jan 2025 21:48:22 -0800
Subject: [PATCH 33/39] (feat) Add trajectory replay for headless mode (#6215)

---
 config.template.toml                        |  5 ++
 docs/modules/usage/configuration-options.md |  5 ++
 openhands/controller/agent_controller.py    | 87 +++++++++++++--------
 openhands/controller/replay.py              | 52 ++++++++++++
 openhands/core/config/app_config.py         |  2 +
 openhands/core/main.py                      | 65 +++++++++++++--
 openhands/core/setup.py                     |  8 +-
 openhands/events/event.py                   |  4 +-
 openhands/events/observation/browse.py      |  2 +-
 9 files changed, 188 insertions(+), 42 deletions(-)
 create mode 100644 openhands/controller/replay.py

diff --git a/config.template.toml b/config.template.toml
index ccb7b1159747..7f256898b347 100644
--- a/config.template.toml
+++ b/config.template.toml
@@ -39,6 +39,11 @@ workspace_base = "./workspace"
 # If it's a folder, the session id will be used as the file name
 #save_trajectory_path="./trajectories"
 
+# Path to replay a trajectory, must be a file path
+# If provided, trajectory will be loaded and replayed before the
+# agent responds to any user instruction
+#replay_trajectory_path = ""
+
 # File store path
 #file_store_path = "/tmp/file_store"
 
diff --git a/docs/modules/usage/configuration-options.md b/docs/modules/usage/configuration-options.md
index a3c11de52ed8..ff0aa5674cc8 100644
--- a/docs/modules/usage/configuration-options.md
+++ b/docs/modules/usage/configuration-options.md
@@ -55,6 +55,11 @@ The core configuration options are defined in the `[core]` section of the `confi
   - Default: `"./trajectories"`
   - Description: Path to store trajectories (can be a folder or a file). If it's a folder, the trajectories will be saved in a file named with the session id name and .json extension, in that folder.
 
+- `replay_trajectory_path`
+  - Type: `str`
+  - Default: `""`
+  - Description: Path to load a trajectory and replay. If given, must be a path to the trajectory file in JSON format. The actions in the trajectory file would be replayed first before any user instruction is executed.
+
 ### File Store
 - `file_store_path`
   - Type: `str`
diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py
index 521968c88d12..87bdd08173b9 100644
--- a/openhands/controller/agent_controller.py
+++ b/openhands/controller/agent_controller.py
@@ -12,6 +12,7 @@
 )
 
 from openhands.controller.agent import Agent
+from openhands.controller.replay import ReplayManager
 from openhands.controller.state.state import State, TrafficControlState
 from openhands.controller.stuck import StuckDetector
 from openhands.core.config import AgentConfig, LLMConfig
@@ -90,6 +91,7 @@ def __init__(
         is_delegate: bool = False,
         headless_mode: bool = True,
         status_callback: Callable | None = None,
+        replay_events: list[Event] | None = None,
     ):
         """Initializes a new instance of the AgentController class.
 
@@ -108,6 +110,7 @@ def __init__(
             is_delegate: Whether this controller is a delegate.
             headless_mode: Whether the agent is run in headless mode.
             status_callback: Optional callback function to handle status updates.
+            replay_events: A list of logs to replay.
         """
         self.id = sid
         self.agent = agent
@@ -139,6 +142,9 @@ def __init__(
         self._stuck_detector = StuckDetector(self.state)
         self.status_callback = status_callback
 
+        # replay-related
+        self._replay_manager = ReplayManager(replay_events)
+
     async def close(self) -> None:
         """Closes the agent controller, canceling any ongoing tasks and unsubscribing from the event stream.
 
@@ -234,6 +240,11 @@ async def _step_with_exception_handling(self):
             await self._react_to_exception(reported)
 
     def should_step(self, event: Event) -> bool:
+        """
+        Whether the agent should take a step based on an event. In general,
+        the agent should take a step if it receives a message from the user,
+        or observes something in the environment (after acting).
+        """
         # it might be the delegate's day in the sun
         if self.delegate is not None:
             return False
@@ -641,42 +652,50 @@ async def _step(self) -> None:
 
         self.update_state_before_step()
         action: Action = NullAction()
-        try:
-            action = self.agent.step(self.state)
-            if action is None:
-                raise LLMNoActionError('No action was returned')
-        except (
-            LLMMalformedActionError,
-            LLMNoActionError,
-            LLMResponseError,
-            FunctionCallValidationError,
-            FunctionCallNotExistsError,
-        ) as e:
-            self.event_stream.add_event(
-                ErrorObservation(
-                    content=str(e),
-                ),
-                EventSource.AGENT,
-            )
-            return
-        except (ContextWindowExceededError, BadRequestError) as e:
-            # FIXME: this is a hack until a litellm fix is confirmed
-            # Check if this is a nested context window error
-            error_str = str(e).lower()
-            if (
-                'contextwindowexceedederror' in error_str
-                or 'prompt is too long' in error_str
-                or isinstance(e, ContextWindowExceededError)
-            ):
-                # When context window is exceeded, keep roughly half of agent interactions
-                self.state.history = self._apply_conversation_window(self.state.history)
 
-                # Save the ID of the first event in our truncated history for future reloading
-                if self.state.history:
-                    self.state.start_id = self.state.history[0].id
-                # Don't add error event - let the agent retry with reduced context
+        if self._replay_manager.should_replay():
+            # in replay mode, we don't let the agent to proceed
+            # instead, we replay the action from the replay trajectory
+            action = self._replay_manager.step()
+        else:
+            try:
+                action = self.agent.step(self.state)
+                if action is None:
+                    raise LLMNoActionError('No action was returned')
+            except (
+                LLMMalformedActionError,
+                LLMNoActionError,
+                LLMResponseError,
+                FunctionCallValidationError,
+                FunctionCallNotExistsError,
+            ) as e:
+                self.event_stream.add_event(
+                    ErrorObservation(
+                        content=str(e),
+                    ),
+                    EventSource.AGENT,
+                )
                 return
-            raise
+            except (ContextWindowExceededError, BadRequestError) as e:
+                # FIXME: this is a hack until a litellm fix is confirmed
+                # Check if this is a nested context window error
+                error_str = str(e).lower()
+                if (
+                    'contextwindowexceedederror' in error_str
+                    or 'prompt is too long' in error_str
+                    or isinstance(e, ContextWindowExceededError)
+                ):
+                    # When context window is exceeded, keep roughly half of agent interactions
+                    self.state.history = self._apply_conversation_window(
+                        self.state.history
+                    )
+
+                    # Save the ID of the first event in our truncated history for future reloading
+                    if self.state.history:
+                        self.state.start_id = self.state.history[0].id
+                    # Don't add error event - let the agent retry with reduced context
+                    return
+                raise
 
         if action.runnable:
             if self.state.confirmation_mode and (
diff --git a/openhands/controller/replay.py b/openhands/controller/replay.py
new file mode 100644
index 000000000000..c960d4a1fb6d
--- /dev/null
+++ b/openhands/controller/replay.py
@@ -0,0 +1,52 @@
+from openhands.core.logger import openhands_logger as logger
+from openhands.events.action.action import Action
+from openhands.events.event import Event, EventSource
+
+
+class ReplayManager:
+    """ReplayManager manages the lifecycle of a replay session of a given trajectory.
+
+    Replay manager keeps track of a list of events, replays actions, and ignore
+    messages and observations. It could lead to unexpected or even errorneous
+    results if any action is non-deterministic, or if the initial state before
+    the replay session is different from the initial state of the trajectory.
+    """
+
+    def __init__(self, replay_events: list[Event] | None):
+        if replay_events:
+            logger.info(f'Replay logs loaded, events length = {len(replay_events)}')
+        self.replay_events = replay_events
+        self.replay_mode = bool(replay_events)
+        self.replay_index = 0
+
+    def _replayable(self) -> bool:
+        return (
+            self.replay_events is not None
+            and self.replay_index < len(self.replay_events)
+            and isinstance(self.replay_events[self.replay_index], Action)
+            and self.replay_events[self.replay_index].source != EventSource.USER
+        )
+
+    def should_replay(self) -> bool:
+        """
+        Whether the controller is in trajectory replay mode, and the replay
+        hasn't finished. Note: after the replay is finished, the user and
+        the agent could continue to message/act.
+
+        This method also moves "replay_index" to the next action, if applicable.
+        """
+        if not self.replay_mode:
+            return False
+
+        assert self.replay_events is not None
+        while self.replay_index < len(self.replay_events) and not self._replayable():
+            self.replay_index += 1
+
+        return self._replayable()
+
+    def step(self) -> Action:
+        assert self.replay_events is not None
+        event = self.replay_events[self.replay_index]
+        assert isinstance(event, Action)
+        self.replay_index += 1
+        return event
diff --git a/openhands/core/config/app_config.py b/openhands/core/config/app_config.py
index 468d37572fe2..8c995d1ee3db 100644
--- a/openhands/core/config/app_config.py
+++ b/openhands/core/config/app_config.py
@@ -28,6 +28,7 @@ class AppConfig(BaseModel):
         file_store: Type of file store to use.
         file_store_path: Path to the file store.
         save_trajectory_path: Either a folder path to store trajectories with auto-generated filenames, or a designated trajectory file path.
+        replay_trajectory_path: Path to load trajectory and replay. If provided, trajectory would be replayed first before user's instruction.
         workspace_base: Base path for the workspace. Defaults to `./workspace` as absolute path.
         workspace_mount_path: Path to mount the workspace. Defaults to `workspace_base`.
         workspace_mount_path_in_sandbox: Path to mount the workspace in sandbox. Defaults to `/workspace`.
@@ -55,6 +56,7 @@ class AppConfig(BaseModel):
     file_store: str = Field(default='local')
     file_store_path: str = Field(default='/tmp/openhands_file_store')
     save_trajectory_path: str | None = Field(default=None)
+    replay_trajectory_path: str | None = Field(default=None)
     workspace_base: str | None = Field(default=None)
     workspace_mount_path: str | None = Field(default=None)
     workspace_mount_path_in_sandbox: str = Field(default='/workspace')
diff --git a/openhands/core/main.py b/openhands/core/main.py
index b27cac1e586d..5c3b38a21b64 100644
--- a/openhands/core/main.py
+++ b/openhands/core/main.py
@@ -2,6 +2,7 @@
 import json
 import os
 import sys
+from pathlib import Path
 from typing import Callable, Protocol
 
 import openhands.agenthub  # noqa F401 (we import this to get the agents registered)
@@ -22,10 +23,11 @@
     generate_sid,
 )
 from openhands.events import EventSource, EventStreamSubscriber
-from openhands.events.action import MessageAction
+from openhands.events.action import MessageAction, NullAction
 from openhands.events.action.action import Action
 from openhands.events.event import Event
 from openhands.events.observation import AgentStateChangedObservation
+from openhands.events.serialization import event_from_dict
 from openhands.events.serialization.event import event_to_trajectory
 from openhands.runtime.base import Runtime
 
@@ -101,7 +103,17 @@ async def run_controller(
     if agent is None:
         agent = create_agent(runtime, config)
 
-    controller, initial_state = create_controller(agent, runtime, config)
+    replay_events: list[Event] | None = None
+    if config.replay_trajectory_path:
+        logger.info('Trajectory replay is enabled')
+        assert isinstance(initial_user_action, NullAction)
+        replay_events, initial_user_action = load_replay_log(
+            config.replay_trajectory_path
+        )
+
+    controller, initial_state = create_controller(
+        agent, runtime, config, replay_events=replay_events
+    )
 
     assert isinstance(
         initial_user_action, Action
@@ -194,21 +206,64 @@ def auto_continue_response(
     return message
 
 
+def load_replay_log(trajectory_path: str) -> tuple[list[Event] | None, Action]:
+    """
+    Load trajectory from given path, serialize it to a list of events, and return
+    two things:
+    1) A list of events except the first action
+    2) First action (user message, a.k.a. initial task)
+    """
+    try:
+        path = Path(trajectory_path).resolve()
+
+        if not path.exists():
+            raise ValueError(f'Trajectory file not found: {path}')
+
+        if not path.is_file():
+            raise ValueError(f'Trajectory path is a directory, not a file: {path}')
+
+        with open(path, 'r', encoding='utf-8') as file:
+            data = json.load(file)
+            if not isinstance(data, list):
+                raise ValueError(
+                    f'Expected a list in {path}, got {type(data).__name__}'
+                )
+            events = []
+            for item in data:
+                event = event_from_dict(item)
+                # cannot add an event with _id to event stream
+                event._id = None  # type: ignore[attr-defined]
+                events.append(event)
+            assert isinstance(events[0], MessageAction)
+            return events[1:], events[0]
+    except json.JSONDecodeError as e:
+        raise ValueError(f'Invalid JSON format in {trajectory_path}: {e}')
+
+
 if __name__ == '__main__':
     args = parse_arguments()
 
+    config = setup_config_from_args(args)
+
     # Determine the task
+    task_str = ''
     if args.file:
         task_str = read_task_from_file(args.file)
     elif args.task:
         task_str = args.task
     elif not sys.stdin.isatty():
         task_str = read_task_from_stdin()
+
+    initial_user_action: Action = NullAction()
+    if config.replay_trajectory_path:
+        if task_str:
+            raise ValueError(
+                'User-specified task is not supported under trajectory replay mode'
+            )
+    elif task_str:
+        initial_user_action = MessageAction(content=task_str)
     else:
         raise ValueError('No task provided. Please specify a task through -t, -f.')
-    initial_user_action: MessageAction = MessageAction(content=task_str)
-
-    config = setup_config_from_args(args)
 
     # Set session name
     session_name = args.name
diff --git a/openhands/core/setup.py b/openhands/core/setup.py
index 28888478017a..82bdaf0c204b 100644
--- a/openhands/core/setup.py
+++ b/openhands/core/setup.py
@@ -11,6 +11,7 @@
 )
 from openhands.core.logger import openhands_logger as logger
 from openhands.events import EventStream
+from openhands.events.event import Event
 from openhands.llm.llm import LLM
 from openhands.runtime import get_runtime_cls
 from openhands.runtime.base import Runtime
@@ -78,7 +79,11 @@ def create_agent(runtime: Runtime, config: AppConfig) -> Agent:
 
 
 def create_controller(
-    agent: Agent, runtime: Runtime, config: AppConfig, headless_mode: bool = True
+    agent: Agent,
+    runtime: Runtime,
+    config: AppConfig,
+    headless_mode: bool = True,
+    replay_events: list[Event] | None = None,
 ) -> Tuple[AgentController, State | None]:
     event_stream = runtime.event_stream
     initial_state = None
@@ -101,6 +106,7 @@ def create_controller(
         initial_state=initial_state,
         headless_mode=headless_mode,
         confirmation_mode=config.security.confirmation_mode,
+        replay_events=replay_events,
     )
     return (controller, initial_state)
 
diff --git a/openhands/events/event.py b/openhands/events/event.py
index 1bdece59eb75..9d7af19160ca 100644
--- a/openhands/events/event.py
+++ b/openhands/events/event.py
@@ -24,6 +24,8 @@ class FileReadSource(str, Enum):
 
 @dataclass
 class Event:
+    INVALID_ID = -1
+
     @property
     def message(self) -> str | None:
         if hasattr(self, '_message'):
@@ -34,7 +36,7 @@ def message(self) -> str | None:
     def id(self) -> int:
         if hasattr(self, '_id'):
             return self._id  # type: ignore[attr-defined]
-        return -1
+        return Event.INVALID_ID
 
     @property
     def timestamp(self):
diff --git a/openhands/events/observation/browse.py b/openhands/events/observation/browse.py
index 2ab73f047d8a..bc347d657473 100644
--- a/openhands/events/observation/browse.py
+++ b/openhands/events/observation/browse.py
@@ -12,7 +12,7 @@ class BrowserOutputObservation(Observation):
 
     url: str
     trigger_by_action: str
-    screenshot: str = field(repr=False)  # don't show in repr
+    screenshot: str = field(repr=False, default='')  # don't show in repr
     error: bool = False
     observation: str = ObservationType.BROWSE
     # do not include in the memory

From 2b04ee2e6281c4528e4be17a37734130bc1c75a4 Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Sat, 18 Jan 2025 14:02:59 -0500
Subject: [PATCH 34/39] feat(eval): reliability improvement for SWE-Bench
 eval_infer (#6347)

---
 evaluation/utils/shared.py                    |  5 ++++-
 .../runtime/impl/remote/remote_runtime.py     | 20 ++++++++++---------
 2 files changed, 15 insertions(+), 10 deletions(-)

diff --git a/evaluation/utils/shared.py b/evaluation/utils/shared.py
index 5eafac1db61e..0f8ac8fa8332 100644
--- a/evaluation/utils/shared.py
+++ b/evaluation/utils/shared.py
@@ -355,7 +355,9 @@ def _process_instance_wrapper(
             )
             # e is likely an EvalException, so we can't directly infer it from type
             # but rather check if it's a fatal error
-            if is_fatal_runtime_error(str(e)):
+            # But it can also be AgentRuntime**Error (e.g., swe_bench/eval_infer.py)
+            _error_str = type(e).__name__ + ': ' + str(e)
+            if is_fatal_runtime_error(_error_str):
                 runtime_failure_count += 1
                 msg += f'Runtime disconnected error detected for instance {instance.instance_id}, runtime failure count: {runtime_failure_count}'
                 msg += '\n' + '-' * 10 + '\n'
@@ -531,6 +533,7 @@ def is_fatal_runtime_error(error: str | None) -> bool:
         return False
 
     FATAL_RUNTIME_ERRORS = [
+        AgentRuntimeTimeoutError,
         AgentRuntimeUnavailableError,
         AgentRuntimeDisconnectedError,
         AgentRuntimeNotFoundError,
diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py
index 54947720204f..c3a7353aefaa 100644
--- a/openhands/runtime/impl/remote/remote_runtime.py
+++ b/openhands/runtime/impl/remote/remote_runtime.py
@@ -60,6 +60,12 @@ def __init__(
             )
         self.session.headers.update({'X-API-Key': self.config.sandbox.api_key})
 
+        if self.config.workspace_base is not None:
+            self.log(
+                'debug',
+                'Setting workspace_base is not supported in the remote runtime.',
+            )
+
         self.runtime_builder = RemoteRuntimeBuilder(
             self.config.sandbox.remote_runtime_api_url,
             self.config.sandbox.api_key,
@@ -70,12 +76,6 @@ def __init__(
         self.available_hosts: dict[str, int] = {}
         self._runtime_initialized: bool = False
 
-        if self.config.workspace_base is not None:
-            self.log(
-                'debug',
-                'Setting workspace_base is not supported in the remote runtime.',
-            )
-
     def log(self, level: str, message: str) -> None:
         message = f'[runtime session_id={self.sid} runtime_id={self.runtime_id or "unknown"}] {message}'
         getattr(logger, level)(message, stacklevel=2)
@@ -230,7 +230,7 @@ def _start_runtime(self):
                 f'Runtime started. URL: {self.runtime_url}',
             )
         except requests.HTTPError as e:
-            self.log('error', f'Unable to start runtime: {e}')
+            self.log('error', f'Unable to start runtime: {str(e)}')
             raise AgentRuntimeUnavailableError() from e
 
     def _resume_runtime(self):
@@ -315,10 +315,11 @@ def _wait_until_alive_impl(self):
                 self.check_if_alive()
             except requests.HTTPError as e:
                 self.log(
-                    'warning', f"Runtime /alive failed, but pod says it's ready: {e}"
+                    'warning',
+                    f"Runtime /alive failed, but pod says it's ready: {str(e)}",
                 )
                 raise AgentRuntimeNotReadyError(
-                    f'Runtime /alive failed to respond with 200: {e}'
+                    f'Runtime /alive failed to respond with 200: {str(e)}'
                 )
             return
         elif (
@@ -363,6 +364,7 @@ def close(self):
                 ):
                     self.log('debug', 'Runtime stopped.')
         except Exception as e:
+            self.log('error', f'Unable to stop runtime: {str(e)}')
             raise e
         finally:
             super().close()

From 1b6e444ecba92952f2055fa6c59cf3300235fe6d Mon Sep 17 00:00:00 2001
From: Xingyao Wang <xingyao@all-hands.dev>
Date: Sun, 19 Jan 2025 16:42:00 -0500
Subject: [PATCH 35/39] feat(remote runtime): do not resume runtime if not
 keep_runtime_alive (#6355)

Co-authored-by: Robert Brennan <accounts@rbren.io>
---
 openhands/runtime/impl/remote/remote_runtime.py | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py
index c3a7353aefaa..3deb7ba40814 100644
--- a/openhands/runtime/impl/remote/remote_runtime.py
+++ b/openhands/runtime/impl/remote/remote_runtime.py
@@ -405,8 +405,13 @@ def _send_action_server_request(self, method, url, **kwargs):
                         f'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again. Original error: {e}'
                     ) from e
             elif e.response.status_code == 503:
-                self.log('warning', 'Runtime appears to be paused. Resuming...')
-                self._resume_runtime()
-                return super()._send_action_server_request(method, url, **kwargs)
+                if self.config.sandbox.keep_runtime_alive:
+                    self.log('warning', 'Runtime appears to be paused. Resuming...')
+                    self._resume_runtime()
+                    return super()._send_action_server_request(method, url, **kwargs)
+                else:
+                    raise AgentRuntimeDisconnectedError(
+                        f'Runtime is temporarily unavailable. This may be due to a restart or network issue, please try again. Original error: {e}'
+                    ) from e
             else:
                 raise e

From 03e496fb608e1f47880cf33e5c453f9216734587 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 20 Jan 2025 17:04:22 +0100
Subject: [PATCH 36/39] chore(deps): bump the version-all group with 7 updates
 (#6359)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
 poetry.lock | 156 ++++++++++++++++++++++++++--------------------------
 1 file changed, 78 insertions(+), 78 deletions(-)

diff --git a/poetry.lock b/poetry.lock
index 562f082c479b..c84eed65a096 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -170,13 +170,13 @@ files = [
 
 [[package]]
 name = "anthropic"
-version = "0.43.0"
+version = "0.43.1"
 description = "The official Python library for the anthropic API"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "anthropic-0.43.0-py3-none-any.whl", hash = "sha256:f748a703f77b3244975e1aace3a935840dc653a4714fb6bba644f97cc76847b4"},
-    {file = "anthropic-0.43.0.tar.gz", hash = "sha256:06801f01d317a431d883230024318d48981758058bf7e079f33fb11f64b5a5c1"},
+    {file = "anthropic-0.43.1-py3-none-any.whl", hash = "sha256:20759c25cd0f4072eb966b0180a41c061c156473bbb674da6a3f1e92e1ad78f8"},
+    {file = "anthropic-0.43.1.tar.gz", hash = "sha256:c7f13e4b7b515ac4a3111142310b214527c0fc561485e5bc9b582e49fe3adba2"},
 ]
 
 [package.dependencies]
@@ -552,17 +552,17 @@ files = [
 
 [[package]]
 name = "boto3"
-version = "1.36.1"
+version = "1.36.2"
 description = "The AWS SDK for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "boto3-1.36.1-py3-none-any.whl", hash = "sha256:eb21380d73fec6645439c0d802210f72a0cdb3295b02953f246ff53f512faa8f"},
-    {file = "boto3-1.36.1.tar.gz", hash = "sha256:258ab77225a81d3cf3029c9afe9920cd9dec317689dfadec6f6f0a23130bb60a"},
+    {file = "boto3-1.36.2-py3-none-any.whl", hash = "sha256:76cfc9a705be46e8d22607efacc8d688c064f923d785a01c00b28e9a96425d1a"},
+    {file = "boto3-1.36.2.tar.gz", hash = "sha256:fde1c29996b77274a60b7bc9f741525afa6267bb1716eb644a764fb7c124a0d2"},
 ]
 
 [package.dependencies]
-botocore = ">=1.36.1,<1.37.0"
+botocore = ">=1.36.2,<1.37.0"
 jmespath = ">=0.7.1,<2.0.0"
 s3transfer = ">=0.11.0,<0.12.0"
 
@@ -571,13 +571,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
 
 [[package]]
 name = "botocore"
-version = "1.36.1"
+version = "1.36.2"
 description = "Low-level, data-driven core of boto 3."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "botocore-1.36.1-py3-none-any.whl", hash = "sha256:dec513b4eb8a847d79bbefdcdd07040ed9d44c20b0001136f0890a03d595705a"},
-    {file = "botocore-1.36.1.tar.gz", hash = "sha256:f789a6f272b5b3d8f8756495019785e33868e5e00dd9662a3ee7959ac939bb12"},
+    {file = "botocore-1.36.2-py3-none-any.whl", hash = "sha256:bc3b7e3b573a48af2bd7116b80fe24f9a335b0b67314dcb2697a327d009abf29"},
+    {file = "botocore-1.36.2.tar.gz", hash = "sha256:a1fe6610983f0214b0c7655fe6990b6a731746baf305b182976fc7b568fc3cb0"},
 ]
 
 [package.dependencies]
@@ -3707,13 +3707,13 @@ types-tqdm = "*"
 
 [[package]]
 name = "litellm"
-version = "1.58.2"
+version = "1.59.0"
 description = "Library to easily interface with LLM API providers"
 optional = false
 python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
 files = [
-    {file = "litellm-1.58.2-py3-none-any.whl", hash = "sha256:51b14b2f5e30d2d41a76fbf926d7d882f1fddbbfda8812358cb4bb27d0d27692"},
-    {file = "litellm-1.58.2.tar.gz", hash = "sha256:4e1b7191a86970bbacd30e5315d3b6a0f5fc75a99763c9164116de60c6ac0bf3"},
+    {file = "litellm-1.59.0-py3-none-any.whl", hash = "sha256:b0c8bdee556d5dc2f9c703f7dc831574ea2e339d2e762dd626d014c170b8b587"},
+    {file = "litellm-1.59.0.tar.gz", hash = "sha256:140eecb47952558414d00f7a259fe303fe5f0d073973a28f488fc6938cc45660"},
 ]
 
 [package.dependencies]
@@ -3731,7 +3731,7 @@ tokenizers = "*"
 
 [package.extras]
 extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "resend (>=0.8.0,<0.9.0)"]
-proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=22.0.0,<23.0.0)", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.22.0,<0.23.0)", "uvloop (>=0.21.0,<0.22.0)"]
+proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "backoff", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=22.0.0,<23.0.0)", "orjson (>=3.9.7,<4.0.0)", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0)"]
 
 [[package]]
 name = "llama-cloud"
@@ -4441,13 +4441,13 @@ files = [
 
 [[package]]
 name = "minio"
-version = "7.2.14"
+version = "7.2.15"
 description = "MinIO Python SDK for Amazon S3 Compatible Cloud Storage"
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "minio-7.2.14-py3-none-any.whl", hash = "sha256:868dfe907e1702ce4bec86df1f3ced577a73ca85f344ef898d94fe2b5237f8c1"},
-    {file = "minio-7.2.14.tar.gz", hash = "sha256:f5c24bf236fefd2edc567cd4455dc49a11ad8ff7ac984bb031b849d82f01222a"},
+    {file = "minio-7.2.15-py3-none-any.whl", hash = "sha256:c06ef7a43e5d67107067f77b6c07ebdd68733e5aa7eed03076472410ca19d876"},
+    {file = "minio-7.2.15.tar.gz", hash = "sha256:5247df5d4dca7bfa4c9b20093acd5ad43e82d8710ceb059d79c6eea970f49f79"},
 ]
 
 [package.dependencies]
@@ -4583,12 +4583,12 @@ type = ["mypy (==1.11.2)"]
 
 [[package]]
 name = "modal"
-version = "0.72.21"
+version = "0.72.33"
 description = "Python client library for Modal"
 optional = false
 python-versions = ">=3.9"
 files = [
-    {file = "modal-0.72.21-py3-none-any.whl", hash = "sha256:16ad3b1f9e5e8a7d2b6aa9746f8e3315c44f8901e5e873679ceacbdd956a7ba1"},
+    {file = "modal-0.72.33-py3-none-any.whl", hash = "sha256:ce8ac919c4ae81563ea8a122b1d9cd23c98335b2795b82cb16b9fe96c3d9fd4b"},
 ]
 
 [package.dependencies]
@@ -5076,66 +5076,66 @@ test = ["pytest", "pytest-console-scripts", "pytest-jupyter", "pytest-tornasync"
 
 [[package]]
 name = "numpy"
-version = "2.2.1"
+version = "2.2.2"
 description = "Fundamental package for array computing in Python"
 optional = false
 python-versions = ">=3.10"
 files = [
-    {file = "numpy-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5edb4e4caf751c1518e6a26a83501fda79bff41cc59dac48d70e6d65d4ec4440"},
-    {file = "numpy-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa3017c40d513ccac9621a2364f939d39e550c542eb2a894b4c8da92b38896ab"},
-    {file = "numpy-2.2.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:61048b4a49b1c93fe13426e04e04fdf5a03f456616f6e98c7576144677598675"},
-    {file = "numpy-2.2.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:7671dc19c7019103ca44e8d94917eba8534c76133523ca8406822efdd19c9308"},
-    {file = "numpy-2.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4250888bcb96617e00bfa28ac24850a83c9f3a16db471eca2ee1f1714df0f957"},
-    {file = "numpy-2.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7746f235c47abc72b102d3bce9977714c2444bdfaea7888d241b4c4bb6a78bf"},
-    {file = "numpy-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:059e6a747ae84fce488c3ee397cee7e5f905fd1bda5fb18c66bc41807ff119b2"},
-    {file = "numpy-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f62aa6ee4eb43b024b0e5a01cf65a0bb078ef8c395e8713c6e8a12a697144528"},
-    {file = "numpy-2.2.1-cp310-cp310-win32.whl", hash = "sha256:48fd472630715e1c1c89bf1feab55c29098cb403cc184b4859f9c86d4fcb6a95"},
-    {file = "numpy-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:b541032178a718c165a49638d28272b771053f628382d5e9d1c93df23ff58dbf"},
-    {file = "numpy-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:40f9e544c1c56ba8f1cf7686a8c9b5bb249e665d40d626a23899ba6d5d9e1484"},
-    {file = "numpy-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9b57eaa3b0cd8db52049ed0330747b0364e899e8a606a624813452b8203d5f7"},
-    {file = "numpy-2.2.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bc8a37ad5b22c08e2dbd27df2b3ef7e5c0864235805b1e718a235bcb200cf1cb"},
-    {file = "numpy-2.2.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:9036d6365d13b6cbe8f27a0eaf73ddcc070cae584e5ff94bb45e3e9d729feab5"},
-    {file = "numpy-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51faf345324db860b515d3f364eaa93d0e0551a88d6218a7d61286554d190d73"},
-    {file = "numpy-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38efc1e56b73cc9b182fe55e56e63b044dd26a72128fd2fbd502f75555d92591"},
-    {file = "numpy-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:31b89fa67a8042e96715c68e071a1200c4e172f93b0fbe01a14c0ff3ff820fc8"},
-    {file = "numpy-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c86e2a209199ead7ee0af65e1d9992d1dce7e1f63c4b9a616500f93820658d0"},
-    {file = "numpy-2.2.1-cp311-cp311-win32.whl", hash = "sha256:b34d87e8a3090ea626003f87f9392b3929a7bbf4104a05b6667348b6bd4bf1cd"},
-    {file = "numpy-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:360137f8fb1b753c5cde3ac388597ad680eccbbbb3865ab65efea062c4a1fd16"},
-    {file = "numpy-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:694f9e921a0c8f252980e85bce61ebbd07ed2b7d4fa72d0e4246f2f8aa6642ab"},
-    {file = "numpy-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3683a8d166f2692664262fd4900f207791d005fb088d7fdb973cc8d663626faa"},
-    {file = "numpy-2.2.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:780077d95eafc2ccc3ced969db22377b3864e5b9a0ea5eb347cc93b3ea900315"},
-    {file = "numpy-2.2.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:55ba24ebe208344aa7a00e4482f65742969a039c2acfcb910bc6fcd776eb4355"},
-    {file = "numpy-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b1d07b53b78bf84a96898c1bc139ad7f10fda7423f5fd158fd0f47ec5e01ac7"},
-    {file = "numpy-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5062dc1a4e32a10dc2b8b13cedd58988261416e811c1dc4dbdea4f57eea61b0d"},
-    {file = "numpy-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fce4f615f8ca31b2e61aa0eb5865a21e14f5629515c9151850aa936c02a1ee51"},
-    {file = "numpy-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:67d4cda6fa6ffa073b08c8372aa5fa767ceb10c9a0587c707505a6d426f4e046"},
-    {file = "numpy-2.2.1-cp312-cp312-win32.whl", hash = "sha256:32cb94448be47c500d2c7a95f93e2f21a01f1fd05dd2beea1ccd049bb6001cd2"},
-    {file = "numpy-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:ba5511d8f31c033a5fcbda22dd5c813630af98c70b2661f2d2c654ae3cdfcfc8"},
-    {file = "numpy-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f1d09e520217618e76396377c81fba6f290d5f926f50c35f3a5f72b01a0da780"},
-    {file = "numpy-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ecc47cd7f6ea0336042be87d9e7da378e5c7e9b3c8ad0f7c966f714fc10d821"},
-    {file = "numpy-2.2.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f419290bc8968a46c4933158c91a0012b7a99bb2e465d5ef5293879742f8797e"},
-    {file = "numpy-2.2.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b6c390bfaef8c45a260554888966618328d30e72173697e5cabe6b285fb2348"},
-    {file = "numpy-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:526fc406ab991a340744aad7e25251dd47a6720a685fa3331e5c59fef5282a59"},
-    {file = "numpy-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f74e6fdeb9a265624ec3a3918430205dff1df7e95a230779746a6af78bc615af"},
-    {file = "numpy-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:53c09385ff0b72ba79d8715683c1168c12e0b6e84fb0372e97553d1ea91efe51"},
-    {file = "numpy-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f3eac17d9ec51be534685ba877b6ab5edc3ab7ec95c8f163e5d7b39859524716"},
-    {file = "numpy-2.2.1-cp313-cp313-win32.whl", hash = "sha256:9ad014faa93dbb52c80d8f4d3dcf855865c876c9660cb9bd7553843dd03a4b1e"},
-    {file = "numpy-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:164a829b6aacf79ca47ba4814b130c4020b202522a93d7bff2202bfb33b61c60"},
-    {file = "numpy-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4dfda918a13cc4f81e9118dea249e192ab167a0bb1966272d5503e39234d694e"},
-    {file = "numpy-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:733585f9f4b62e9b3528dd1070ec4f52b8acf64215b60a845fa13ebd73cd0712"},
-    {file = "numpy-2.2.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:89b16a18e7bba224ce5114db863e7029803c179979e1af6ad6a6b11f70545008"},
-    {file = "numpy-2.2.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:676f4eebf6b2d430300f1f4f4c2461685f8269f94c89698d832cdf9277f30b84"},
-    {file = "numpy-2.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f5cdf9f493b35f7e41e8368e7d7b4bbafaf9660cba53fb21d2cd174ec09631"},
-    {file = "numpy-2.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1ad395cf254c4fbb5b2132fee391f361a6e8c1adbd28f2cd8e79308a615fe9d"},
-    {file = "numpy-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:08ef779aed40dbc52729d6ffe7dd51df85796a702afbf68a4f4e41fafdc8bda5"},
-    {file = "numpy-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:26c9c4382b19fcfbbed3238a14abf7ff223890ea1936b8890f058e7ba35e8d71"},
-    {file = "numpy-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:93cf4e045bae74c90ca833cba583c14b62cb4ba2cba0abd2b141ab52548247e2"},
-    {file = "numpy-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:bff7d8ec20f5f42607599f9994770fa65d76edca264a87b5e4ea5629bce12268"},
-    {file = "numpy-2.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7ba9cc93a91d86365a5d270dee221fdc04fb68d7478e6bf6af650de78a8339e3"},
-    {file = "numpy-2.2.1-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:3d03883435a19794e41f147612a77a8f56d4e52822337844fff3d4040a142964"},
-    {file = "numpy-2.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4511d9e6071452b944207c8ce46ad2f897307910b402ea5fa975da32e0102800"},
-    {file = "numpy-2.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5c5cc0cbabe9452038ed984d05ac87910f89370b9242371bd9079cb4af61811e"},
-    {file = "numpy-2.2.1.tar.gz", hash = "sha256:45681fd7128c8ad1c379f0ca0776a8b0c6583d2f69889ddac01559dfe4390918"},
+    {file = "numpy-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7079129b64cb78bdc8d611d1fd7e8002c0a2565da6a47c4df8062349fee90e3e"},
+    {file = "numpy-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec6c689c61df613b783aeb21f945c4cbe6c51c28cb70aae8430577ab39f163e"},
+    {file = "numpy-2.2.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:40c7ff5da22cd391944a28c6a9c638a5eef77fcf71d6e3a79e1d9d9e82752715"},
+    {file = "numpy-2.2.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:995f9e8181723852ca458e22de5d9b7d3ba4da3f11cc1cb113f093b271d7965a"},
+    {file = "numpy-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b78ea78450fd96a498f50ee096f69c75379af5138f7881a51355ab0e11286c97"},
+    {file = "numpy-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fbe72d347fbc59f94124125e73fc4976a06927ebc503ec5afbfb35f193cd957"},
+    {file = "numpy-2.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8e6da5cffbbe571f93588f562ed130ea63ee206d12851b60819512dd3e1ba50d"},
+    {file = "numpy-2.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09d6a2032faf25e8d0cadde7fd6145118ac55d2740132c1d845f98721b5ebcfd"},
+    {file = "numpy-2.2.2-cp310-cp310-win32.whl", hash = "sha256:159ff6ee4c4a36a23fe01b7c3d07bd8c14cc433d9720f977fcd52c13c0098160"},
+    {file = "numpy-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:64bd6e1762cd7f0986a740fee4dff927b9ec2c5e4d9a28d056eb17d332158014"},
+    {file = "numpy-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:642199e98af1bd2b6aeb8ecf726972d238c9877b0f6e8221ee5ab945ec8a2189"},
+    {file = "numpy-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d9fc9d812c81e6168b6d405bf00b8d6739a7f72ef22a9214c4241e0dc70b323"},
+    {file = "numpy-2.2.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:c7d1fd447e33ee20c1f33f2c8e6634211124a9aabde3c617687d8b739aa69eac"},
+    {file = "numpy-2.2.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:451e854cfae0febe723077bd0cf0a4302a5d84ff25f0bfece8f29206c7bed02e"},
+    {file = "numpy-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd249bc894af67cbd8bad2c22e7cbcd46cf87ddfca1f1289d1e7e54868cc785c"},
+    {file = "numpy-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02935e2c3c0c6cbe9c7955a8efa8908dd4221d7755644c59d1bba28b94fd334f"},
+    {file = "numpy-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a972cec723e0563aa0823ee2ab1df0cb196ed0778f173b381c871a03719d4826"},
+    {file = "numpy-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6d6a0910c3b4368d89dde073e630882cdb266755565155bc33520283b2d9df8"},
+    {file = "numpy-2.2.2-cp311-cp311-win32.whl", hash = "sha256:860fd59990c37c3ef913c3ae390b3929d005243acca1a86facb0773e2d8d9e50"},
+    {file = "numpy-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:da1eeb460ecce8d5b8608826595c777728cdf28ce7b5a5a8c8ac8d949beadcf2"},
+    {file = "numpy-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ac9bea18d6d58a995fac1b2cb4488e17eceeac413af014b1dd26170b766d8467"},
+    {file = "numpy-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae9f0c2d889b7b2d88a3791f6c09e2ef827c2446f1c4a3e3e76328ee4afd9a"},
+    {file = "numpy-2.2.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3074634ea4d6df66be04f6728ee1d173cfded75d002c75fac79503a880bf3825"},
+    {file = "numpy-2.2.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ec0636d3f7d68520afc6ac2dc4b8341ddb725039de042faf0e311599f54eb37"},
+    {file = "numpy-2.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ffbb1acd69fdf8e89dd60ef6182ca90a743620957afb7066385a7bbe88dc748"},
+    {file = "numpy-2.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0349b025e15ea9d05c3d63f9657707a4e1d471128a3b1d876c095f328f8ff7f0"},
+    {file = "numpy-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:463247edcee4a5537841d5350bc87fe8e92d7dd0e8c71c995d2c6eecb8208278"},
+    {file = "numpy-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9dd47ff0cb2a656ad69c38da850df3454da88ee9a6fde0ba79acceee0e79daba"},
+    {file = "numpy-2.2.2-cp312-cp312-win32.whl", hash = "sha256:4525b88c11906d5ab1b0ec1f290996c0020dd318af8b49acaa46f198b1ffc283"},
+    {file = "numpy-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:5acea83b801e98541619af398cc0109ff48016955cc0818f478ee9ef1c5c3dcb"},
+    {file = "numpy-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b208cfd4f5fe34e1535c08983a1a6803fdbc7a1e86cf13dd0c61de0b51a0aadc"},
+    {file = "numpy-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d0bbe7dd86dca64854f4b6ce2ea5c60b51e36dfd597300057cf473d3615f2369"},
+    {file = "numpy-2.2.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:22ea3bb552ade325530e72a0c557cdf2dea8914d3a5e1fecf58fa5dbcc6f43cd"},
+    {file = "numpy-2.2.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:128c41c085cab8a85dc29e66ed88c05613dccf6bc28b3866cd16050a2f5448be"},
+    {file = "numpy-2.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:250c16b277e3b809ac20d1f590716597481061b514223c7badb7a0f9993c7f84"},
+    {file = "numpy-2.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0c8854b09bc4de7b041148d8550d3bd712b5c21ff6a8ed308085f190235d7ff"},
+    {file = "numpy-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6fb9c32a91ec32a689ec6410def76443e3c750e7cfc3fb2206b985ffb2b85f0"},
+    {file = "numpy-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:57b4012e04cc12b78590a334907e01b3a85efb2107df2b8733ff1ed05fce71de"},
+    {file = "numpy-2.2.2-cp313-cp313-win32.whl", hash = "sha256:4dbd80e453bd34bd003b16bd802fac70ad76bd463f81f0c518d1245b1c55e3d9"},
+    {file = "numpy-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:5a8c863ceacae696aff37d1fd636121f1a512117652e5dfb86031c8d84836369"},
+    {file = "numpy-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b3482cb7b3325faa5f6bc179649406058253d91ceda359c104dac0ad320e1391"},
+    {file = "numpy-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9491100aba630910489c1d0158034e1c9a6546f0b1340f716d522dc103788e39"},
+    {file = "numpy-2.2.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:41184c416143defa34cc8eb9d070b0a5ba4f13a0fa96a709e20584638254b317"},
+    {file = "numpy-2.2.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7dca87ca328f5ea7dafc907c5ec100d187911f94825f8700caac0b3f4c384b49"},
+    {file = "numpy-2.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bc61b307655d1a7f9f4b043628b9f2b721e80839914ede634e3d485913e1fb2"},
+    {file = "numpy-2.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fad446ad0bc886855ddf5909cbf8cb5d0faa637aaa6277fb4b19ade134ab3c7"},
+    {file = "numpy-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:149d1113ac15005652e8d0d3f6fd599360e1a708a4f98e43c9c77834a28238cb"},
+    {file = "numpy-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:106397dbbb1896f99e044efc90360d098b3335060375c26aa89c0d8a97c5f648"},
+    {file = "numpy-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:0eec19f8af947a61e968d5429f0bd92fec46d92b0008d0a6685b40d6adf8a4f4"},
+    {file = "numpy-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:97b974d3ba0fb4612b77ed35d7627490e8e3dff56ab41454d9e8b23448940576"},
+    {file = "numpy-2.2.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b0531f0b0e07643eb089df4c509d30d72c9ef40defa53e41363eca8a8cc61495"},
+    {file = "numpy-2.2.2-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:e9e82dcb3f2ebbc8cb5ce1102d5f1c5ed236bf8a11730fb45ba82e2841ec21df"},
+    {file = "numpy-2.2.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0d4142eb40ca6f94539e4db929410f2a46052a0fe7a2c1c59f6179c39938d2a"},
+    {file = "numpy-2.2.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:356ca982c188acbfa6af0d694284d8cf20e95b1c3d0aefa8929376fea9146f60"},
+    {file = "numpy-2.2.2.tar.gz", hash = "sha256:ed6906f61834d687738d25988ae117683705636936cc605be0bb208b23df4d8f"},
 ]
 
 [[package]]
@@ -5364,13 +5364,13 @@ sympy = "*"
 
 [[package]]
 name = "openai"
-version = "1.59.7"
+version = "1.59.9"
 description = "The official Python library for the openai API"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "openai-1.59.7-py3-none-any.whl", hash = "sha256:cfa806556226fa96df7380ab2e29814181d56fea44738c2b0e581b462c268692"},
-    {file = "openai-1.59.7.tar.gz", hash = "sha256:043603def78c00befb857df9f0a16ee76a3af5984ba40cb7ee5e2f40db4646bf"},
+    {file = "openai-1.59.9-py3-none-any.whl", hash = "sha256:61a0608a1313c08ddf92fe793b6dbd1630675a1fe3866b2f96447ce30050c448"},
+    {file = "openai-1.59.9.tar.gz", hash = "sha256:ec1a20b0351b4c3e65c6292db71d8233515437c6065efd4fd50edeb55df5f5d2"},
 ]
 
 [package.dependencies]

From 541a445dfc00b2f594a5985c6f2a4fc242196668 Mon Sep 17 00:00:00 2001
From: tofarr <tofarr@gmail.com>
Date: Mon, 20 Jan 2025 09:47:57 -0700
Subject: [PATCH 37/39] Fix: API meta for OpenHands (#6295)

---
 openhands/server/app.py | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/openhands/server/app.py b/openhands/server/app.py
index 1ece8476151a..151e16eb3d56 100644
--- a/openhands/server/app.py
+++ b/openhands/server/app.py
@@ -9,6 +9,7 @@
 )
 
 import openhands.agenthub  # noqa F401 (we import this to get the agents registered)
+from openhands import __version__
 from openhands.server.middleware import (
     AttachConversationMiddleware,
     InMemoryRateLimiter,
@@ -36,7 +37,12 @@ async def _lifespan(app: FastAPI):
         yield
 
 
-app = FastAPI(lifespan=_lifespan)
+app = FastAPI(
+    title='OpenHands',
+    description='OpenHands: Code Less, Make More',
+    version=__version__,
+    lifespan=_lifespan,
+)
 app.add_middleware(
     LocalhostCORSMiddleware,
     allow_credentials=True,

From 06121bf20f865657324534ea87bd34ee7e7a4037 Mon Sep 17 00:00:00 2001
From: Boxuan Li <liboxuan@connect.hku.hk>
Date: Mon, 20 Jan 2025 10:11:32 -0800
Subject: [PATCH 38/39] chore(deps): Revert vite upgrade (#6349)

---
 frontend/package-lock.json | 327 +++++++++++++++++--------------------
 frontend/package.json      |   2 +-
 2 files changed, 155 insertions(+), 174 deletions(-)

diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 48bb85cd4799..70068c558317 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -43,7 +43,7 @@
         "sirv-cli": "^3.0.0",
         "socket.io-client": "^4.8.1",
         "tailwind-merge": "^2.6.0",
-        "vite": "^6.0.7",
+        "vite": "^5.4.11",
         "web-vitals": "^3.5.2",
         "ws": "^8.18.0"
       },
@@ -826,378 +826,371 @@
       }
     },
     "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
-      "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
       "cpu": [
         "ppc64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "aix"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/android-arm": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
-      "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
       "cpu": [
         "arm"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "android"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/android-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
-      "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
       "cpu": [
         "arm64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "android"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/android-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
-      "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
       "cpu": [
         "x64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "android"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
-      "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
       "cpu": [
         "arm64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "darwin"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/darwin-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
-      "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
       "cpu": [
         "x64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "darwin"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
-      "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
       "cpu": [
         "arm64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "freebsd"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
-      "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
       "cpu": [
         "x64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "freebsd"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-arm": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
-      "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
       "cpu": [
         "arm"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
-      "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
       "cpu": [
         "arm64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-ia32": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
-      "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
       "cpu": [
         "ia32"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-loong64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
-      "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
       "cpu": [
         "loong64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
-      "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
       "cpu": [
         "mips64el"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
-      "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
       "cpu": [
         "ppc64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
-      "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
       "cpu": [
         "riscv64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-s390x": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
-      "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
       "cpu": [
         "s390x"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/linux-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
-      "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
       "cpu": [
         "x64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "linux"
       ],
       "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
-      "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
-      "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
       "cpu": [
         "x64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "netbsd"
       ],
       "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
-      "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
-      "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
       "cpu": [
         "x64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "openbsd"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/sunos-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
-      "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
       "cpu": [
         "x64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "sunos"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/win32-arm64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
-      "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
       "cpu": [
         "arm64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/win32-ia32": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
-      "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
       "cpu": [
         "ia32"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@esbuild/win32-x64": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
-      "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
       "cpu": [
         "x64"
       ],
+      "license": "MIT",
       "optional": true,
       "os": [
         "win32"
       ],
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       }
     },
     "node_modules/@eslint-community/eslint-utils": {
@@ -8340,42 +8333,41 @@
       }
     },
     "node_modules/esbuild": {
-      "version": "0.24.2",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
-      "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
       "hasInstallScript": true,
+      "license": "MIT",
       "bin": {
         "esbuild": "bin/esbuild"
       },
       "engines": {
-        "node": ">=18"
+        "node": ">=12"
       },
       "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.24.2",
-        "@esbuild/android-arm": "0.24.2",
-        "@esbuild/android-arm64": "0.24.2",
-        "@esbuild/android-x64": "0.24.2",
-        "@esbuild/darwin-arm64": "0.24.2",
-        "@esbuild/darwin-x64": "0.24.2",
-        "@esbuild/freebsd-arm64": "0.24.2",
-        "@esbuild/freebsd-x64": "0.24.2",
-        "@esbuild/linux-arm": "0.24.2",
-        "@esbuild/linux-arm64": "0.24.2",
-        "@esbuild/linux-ia32": "0.24.2",
-        "@esbuild/linux-loong64": "0.24.2",
-        "@esbuild/linux-mips64el": "0.24.2",
-        "@esbuild/linux-ppc64": "0.24.2",
-        "@esbuild/linux-riscv64": "0.24.2",
-        "@esbuild/linux-s390x": "0.24.2",
-        "@esbuild/linux-x64": "0.24.2",
-        "@esbuild/netbsd-arm64": "0.24.2",
-        "@esbuild/netbsd-x64": "0.24.2",
-        "@esbuild/openbsd-arm64": "0.24.2",
-        "@esbuild/openbsd-x64": "0.24.2",
-        "@esbuild/sunos-x64": "0.24.2",
-        "@esbuild/win32-arm64": "0.24.2",
-        "@esbuild/win32-ia32": "0.24.2",
-        "@esbuild/win32-x64": "0.24.2"
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
       }
     },
     "node_modules/escalade": {
@@ -16720,19 +16712,20 @@
       }
     },
     "node_modules/vite": {
-      "version": "6.0.7",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz",
-      "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==",
+      "version": "5.4.11",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
+      "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
+      "license": "MIT",
       "dependencies": {
-        "esbuild": "^0.24.2",
-        "postcss": "^8.4.49",
-        "rollup": "^4.23.0"
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
       },
       "bin": {
         "vite": "bin/vite.js"
       },
       "engines": {
-        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+        "node": "^18.0.0 || >=20.0.0"
       },
       "funding": {
         "url": "https://github.com/vitejs/vite?sponsor=1"
@@ -16741,25 +16734,19 @@
         "fsevents": "~2.3.3"
       },
       "peerDependencies": {
-        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
-        "jiti": ">=1.21.0",
+        "@types/node": "^18.0.0 || >=20.0.0",
         "less": "*",
         "lightningcss": "^1.21.0",
         "sass": "*",
         "sass-embedded": "*",
         "stylus": "*",
         "sugarss": "*",
-        "terser": "^5.16.0",
-        "tsx": "^4.8.1",
-        "yaml": "^2.4.2"
+        "terser": "^5.4.0"
       },
       "peerDependenciesMeta": {
         "@types/node": {
           "optional": true
         },
-        "jiti": {
-          "optional": true
-        },
         "less": {
           "optional": true
         },
@@ -16780,12 +16767,6 @@
         },
         "terser": {
           "optional": true
-        },
-        "tsx": {
-          "optional": true
-        },
-        "yaml": {
-          "optional": true
         }
       }
     },
diff --git a/frontend/package.json b/frontend/package.json
index 14566f3be94b..e9c61b2d8432 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -42,7 +42,7 @@
     "sirv-cli": "^3.0.0",
     "socket.io-client": "^4.8.1",
     "tailwind-merge": "^2.6.0",
-    "vite": "^6.0.7",
+    "vite": "^5.4.11",
     "web-vitals": "^3.5.2",
     "ws": "^8.18.0"
   },

From d30211da18310b866b4369b212d109d6aebd23ef Mon Sep 17 00:00:00 2001
From: mamoodi <mamoodiha@gmail.com>
Date: Mon, 20 Jan 2025 13:53:14 -0500
Subject: [PATCH 39/39] Update running OpenHands guide with detailed
 prerequisites (#6366)

---
 README.md                              |  4 +--
 docs/modules/usage/getting-started.mdx |  2 +-
 docs/modules/usage/installation.mdx    | 49 +++++++++++++++++++++++---
 docs/sidebars.ts                       |  2 +-
 4 files changed, 48 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index cdc8694815a6..150263e67e71 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or jump to the [
 ## ⚡ Quick Start
 
 The easiest way to run OpenHands is in Docker.
-See the [Installation](https://docs.all-hands.dev/modules/usage/installation) guide for
+See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installation) guide for
 system requirements and more information.
 
 ```bash
@@ -69,7 +69,7 @@ run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules
 interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
 or run it on tagged issues with [a github action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
 
-Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
+Visit [Running OpenHands](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
 
 > [!CAUTION]
 > OpenHands is meant to be run by a single user on their local workstation.
diff --git a/docs/modules/usage/getting-started.mdx b/docs/modules/usage/getting-started.mdx
index 0a7140b91dd6..b01c4d147686 100644
--- a/docs/modules/usage/getting-started.mdx
+++ b/docs/modules/usage/getting-started.mdx
@@ -1,6 +1,6 @@
 # Getting Started with OpenHands
 
-So you've [installed OpenHands](./installation) and have
+So you've [run OpenHands](./installation) and have
 [set up your LLM](./installation#setup). Now what?
 
 OpenHands can help you tackle a wide variety of engineering tasks. But the technology
diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx
index d35eeb409fa8..d559440fac3b 100644
--- a/docs/modules/usage/installation.mdx
+++ b/docs/modules/usage/installation.mdx
@@ -1,12 +1,51 @@
-# Installation
+# Running OpenHands
 
 ## System Requirements
 
-- Docker version 26.0.0+ or Docker Desktop 4.31.0+.
-- You must be using Linux or Mac OS.
-  - If you are on Windows, you must use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
+- MacOS with [Docker Desktop support](https://docs.docker.com/desktop/setup/install/mac-install/#system-requirements)
+- Linux
+- Windows with [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) and [Docker Desktop support](https://docs.docker.com/desktop/setup/install/windows-install/#system-requirements)
 
-## Start the app
+## Prerequisites
+
+<details>
+  <summary>MacOS</summary>
+  ### Docker Desktop
+
+  1. [Install Docker Desktop on Mac](https://docs.docker.com/desktop/setup/install/mac-install).
+  2. Open Docker Desktop, go to `Settings > Advanced` and ensure `Allow the default Docker socket to be used` is enabled.
+</details>
+
+<details>
+  <summary>Linux</summary>
+
+  :::note
+  Tested with Ubuntu 22.04.
+  :::
+
+  ### Docker Desktop
+
+  1. [Install Docker Desktop on Linux](https://docs.docker.com/desktop/setup/install/linux/).
+
+</details>
+
+<details>
+  <summary>Windows</summary>
+  ### WSL
+
+  1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
+  2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
+
+  ### Docker Desktop
+
+  1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
+  2. Open Docker Desktop, go to `Settings` and confirm the following:
+  - General: `Use the WSL 2 based engine` is enabled.
+  - Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
+
+</details>
+
+## Start the App
 
 The easiest way to run OpenHands is in Docker.
 
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index b7def32dd68f..a8d88d9dfca1 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -5,7 +5,7 @@ const sidebars: SidebarsConfig = {
   docsSidebar: [
     {
       type: 'doc',
-      label: 'Installation',
+      label: 'Running OpenHands',
       id: 'usage/installation',
     },
     {