All Runtime Status Codes should be in the RuntimeStatus enum (#9601)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Tim O'Farrell
2025-07-09 15:34:46 -06:00
committed by GitHub
parent 1f416f616c
commit cf276b2e96
16 changed files with 121 additions and 76 deletions

View File

@@ -5,4 +5,15 @@ export type RuntimeStatus =
| "STATUS$RUNTIME_STARTED"
| "STATUS$SETTING_UP_WORKSPACE"
| "STATUS$SETTING_UP_GIT_HOOKS"
| "STATUS$READY";
| "STATUS$READY"
| "STATUS$ERROR"
| "STATUS$ERROR_RUNTIME_DISCONNECTED"
| "STATUS$ERROR_LLM_AUTHENTICATION"
| "STATUS$ERROR_LLM_SERVICE_UNAVAILABLE"
| "STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR"
| "STATUS$ERROR_LLM_OUT_OF_CREDITS"
| "STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION"
| "CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE"
| "STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR"
| "STATUS$LLM_RETRY"
| "STATUS$ERROR_MEMORY";

View File

@@ -88,11 +88,15 @@ export function getStatusCode(
if (conversationStatus === "STOPPED" || runtimeStatus === "STATUS$STOPPED") {
return I18nKey.CHAT_INTERFACE$STOPPED;
}
if (runtimeStatus === "STATUS$BUILDING_RUNTIME") {
return I18nKey.STATUS$BUILDING_RUNTIME;
}
if (runtimeStatus === "STATUS$STARTING_RUNTIME") {
return I18nKey.STATUS$STARTING_RUNTIME;
if (
runtimeStatus &&
!["STATUS$READY", "STATUS$RUNTIME_STARTED"].includes(runtimeStatus)
) {
const result = (I18nKey as { [key: string]: string })[runtimeStatus];
if (result) {
return result;
}
return runtimeStatus;
}
if (webSocketStatus === "DISCONNECTED") {
return I18nKey.CHAT_INTERFACE$DISCONNECTED;

View File

@@ -75,6 +75,7 @@ from openhands.events.observation import (
from openhands.events.serialization.event import truncate_content
from openhands.llm.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.storage.files import FileStore
# note: RESUME is only available on web GUI
@@ -248,10 +249,10 @@ class AgentController:
self.state.last_error = f'{type(e).__name__}: {str(e)}'
if self.status_callback is not None:
err_id = ''
runtime_status = RuntimeStatus.ERROR
if isinstance(e, AuthenticationError):
err_id = 'STATUS$ERROR_LLM_AUTHENTICATION'
self.state.last_error = err_id
runtime_status = RuntimeStatus.ERROR_LLM_AUTHENTICATION
self.state.last_error = runtime_status.value
elif isinstance(
e,
(
@@ -260,20 +261,20 @@ class AgentController:
APIError,
),
):
err_id = 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE'
self.state.last_error = err_id
runtime_status = RuntimeStatus.ERROR_LLM_SERVICE_UNAVAILABLE
self.state.last_error = runtime_status.value
elif isinstance(e, InternalServerError):
err_id = 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR'
self.state.last_error = err_id
runtime_status = RuntimeStatus.ERROR_LLM_INTERNAL_SERVER_ERROR
self.state.last_error = runtime_status.value
elif isinstance(e, BadRequestError) and 'ExceededBudget' in str(e):
err_id = 'STATUS$ERROR_LLM_OUT_OF_CREDITS'
self.state.last_error = err_id
runtime_status = RuntimeStatus.ERROR_LLM_OUT_OF_CREDITS
self.state.last_error = runtime_status.value
elif isinstance(e, ContentPolicyViolationError) or (
isinstance(e, BadRequestError)
and 'ContentPolicyViolationError' in str(e)
):
err_id = 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION'
self.state.last_error = err_id
runtime_status = RuntimeStatus.ERROR_LLM_CONTENT_POLICY_VIOLATION
self.state.last_error = runtime_status.value
elif isinstance(e, RateLimitError):
# Check if this is the final retry attempt
if (
@@ -283,14 +284,14 @@ class AgentController:
):
# All retries exhausted, set to ERROR state with a special message
self.state.last_error = (
'CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE'
RuntimeStatus.AGENT_RATE_LIMITED_STOPPED_MESSAGE.value
)
await self.set_agent_state_to(AgentState.ERROR)
else:
# Still retrying, set to RATE_LIMITED state
await self.set_agent_state_to(AgentState.RATE_LIMITED)
return
self.status_callback('error', err_id, self.state.last_error)
self.status_callback('error', runtime_status, self.state.last_error)
# Set the agent state to ERROR after storing the reason
await self.set_agent_state_to(AgentState.ERROR)

View File

@@ -5,6 +5,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import AgentState
from openhands.memory.memory import Memory
from openhands.runtime.base import Runtime
from openhands.runtime.runtime_status import RuntimeStatus
async def run_agent_until_done(
@@ -19,7 +20,7 @@ async def run_agent_until_done(
Note that runtime must be connected before being passed in here.
"""
def status_callback(msg_type: str, msg_id: str, msg: str) -> None:
def status_callback(msg_type: str, runtime_status: RuntimeStatus, msg: str) -> None:
if msg_type == 'error':
logger.error(msg)
if controller:

View File

@@ -23,6 +23,7 @@ from openhands.microagent import (
load_microagents_from_dir,
)
from openhands.runtime.base import Runtime
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.utils.prompt import (
ConversationInstructions,
RepositoryInfo,
@@ -133,7 +134,7 @@ class Memory:
except Exception as e:
error_str = f'Error: {str(e.__class__.__name__)}'
logger.error(error_str)
self.send_error_message('STATUS$ERROR_MEMORY', error_str)
self.set_runtime_status(RuntimeStatus.ERROR_MEMORY, error_str)
return
def _on_workspace_context_recall(
@@ -361,22 +362,24 @@ class Memory:
content=conversation_instructions or ''
)
def send_error_message(self, message_id: str, message: str):
def set_runtime_status(self, status: RuntimeStatus, message: str):
"""Sends an error message if the callback function was provided."""
if self.status_callback:
try:
if self.loop is None:
self.loop = asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(
self._send_status_message('error', message_id, message), self.loop
self._set_runtime_status('error', status, message), self.loop
)
except RuntimeError as e:
except (RuntimeError, KeyError) as e:
logger.error(
f'Error sending status message: {e.__class__.__name__}',
stack_info=False,
)
async def _send_status_message(self, msg_type: str, id: str, message: str):
async def _set_runtime_status(
self, msg_type: str, runtime_status: RuntimeStatus, message: str
):
"""Sends a status message to the client."""
if self.status_callback:
self.status_callback(msg_type, id, message)
self.status_callback(msg_type, runtime_status, message)

View File

@@ -113,7 +113,7 @@ class Runtime(FileEditRuntimeMixin):
config: OpenHandsConfig
initial_env_vars: dict[str, str]
attach_to_existing: bool
status_callback: Callable[[str, str, str], None] | None
status_callback: Callable[[str, RuntimeStatus, str], None] | None
runtime_status: RuntimeStatus | None
_runtime_initialized: bool = False
@@ -124,7 +124,7 @@ class Runtime(FileEditRuntimeMixin):
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable[[str, str, str], None] | None = None,
status_callback: Callable[[str, RuntimeStatus, str], None] | None = None,
attach_to_existing: bool = False,
headless_mode: bool = False,
user_id: str | None = None,
@@ -207,16 +207,13 @@ class Runtime(FileEditRuntimeMixin):
message = f'[runtime {self.sid}] {message}'
getattr(logger, level)(message, stacklevel=2)
def set_runtime_status(self, runtime_status: RuntimeStatus):
def set_runtime_status(
self, runtime_status: RuntimeStatus, msg: str = '', level: str = 'info'
):
"""Sends a status message if the callback function was provided."""
self.runtime_status = runtime_status
if self.status_callback:
msg_id: str = runtime_status.value # type: ignore
self.status_callback('info', msg_id, runtime_status.message)
def send_error_message(self, message_id: str, message: str):
if self.status_callback:
self.status_callback('error', message_id, message)
self.status_callback(level, runtime_status, msg)
# ====================================================================
@@ -344,15 +341,13 @@ class Runtime(FileEditRuntimeMixin):
else:
observation = await call_sync_from_async(self.run_action, event)
except Exception as e:
err_id = ''
if isinstance(e, httpx.NetworkError) or isinstance(
e, AgentRuntimeDisconnectedError
):
err_id = 'STATUS$ERROR_RUNTIME_DISCONNECTED'
runtime_status = RuntimeStatus.ERROR
if isinstance(e, (httpx.NetworkError, AgentRuntimeDisconnectedError)):
runtime_status = RuntimeStatus.ERROR_RUNTIME_DISCONNECTED
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, error_message)
self.set_runtime_status(runtime_status, error_message)
return
observation._cause = event.id # type: ignore[attr-defined]
@@ -397,7 +392,7 @@ class Runtime(FileEditRuntimeMixin):
if self.status_callback:
self.status_callback(
'info', 'STATUS$SETTING_UP_WORKSPACE', 'Setting up workspace...'
'info', RuntimeStatus.SETTING_UP_WORKSPACE, 'Setting up workspace...'
)
dir_name = selected_repository.split('/')[-1]
@@ -438,7 +433,7 @@ class Runtime(FileEditRuntimeMixin):
if self.status_callback:
self.status_callback(
'info', 'STATUS$SETTING_UP_WORKSPACE', 'Setting up workspace...'
'info', RuntimeStatus.SETTING_UP_WORKSPACE, 'Setting up workspace...'
)
# setup scripts time out after 10 minutes
@@ -470,7 +465,7 @@ class Runtime(FileEditRuntimeMixin):
if self.status_callback:
self.status_callback(
'info', 'STATUS$SETTING_UP_GIT_HOOKS', 'Setting up git hooks...'
'info', RuntimeStatus.SETTING_UP_GIT_HOOKS, 'Setting up git hooks...'
)
# Ensure the git hooks directory exists

View File

@@ -113,7 +113,7 @@ class CLIRuntime(Runtime):
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable[[str, str, str], None] | None = None,
status_callback: Callable[[str, RuntimeStatus, str], None] | None = None,
attach_to_existing: bool = False,
headless_mode: bool = False,
user_id: str | None = None,

View File

@@ -252,8 +252,8 @@ class KubernetesRuntime(ActionExecutionClient):
await call_sync_from_async(self._wait_until_ready)
except Exception as alive_error:
self.log('error', f'Failed to connect to runtime: {alive_error}')
self.send_error_message(
'ERROR$RUNTIME_CONNECTION',
self.set_runtime_status(
RuntimeStatus.ERROR_RUNTIME_DISCONNECTED,
f'Failed to connect to runtime: {alive_error}',
)
raise AgentRuntimeDisconnectedError(

View File

@@ -134,7 +134,7 @@ class LocalRuntime(ActionExecutionClient):
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable[[str, str, str], None] | None = None,
status_callback: Callable[[str, RuntimeStatus, str], None] | None = None,
attach_to_existing: bool = False,
headless_mode: bool = True,
user_id: str | None = None,

View File

@@ -2,14 +2,23 @@ from enum import Enum
class RuntimeStatus(Enum):
def __init__(self, value: str, message: str):
self._value_ = value
self.message = message
STOPPED = 'STATUS$STOPPED', 'Stopped'
BUILDING_RUNTIME = 'STATUS$BUILDING_RUNTIME', 'Building runtime...'
STARTING_RUNTIME = 'STATUS$STARTING_RUNTIME', 'Starting runtime...'
RUNTIME_STARTED = 'STATUS$RUNTIME_STARTED', 'Runtime started...'
SETTING_UP_WORKSPACE = 'STATUS$SETTING_UP_WORKSPACE', 'Setting up workspace...'
SETTING_UP_GIT_HOOKS = 'STATUS$SETTING_UP_GIT_HOOKS', 'Setting up git hooks...'
READY = 'STATUS$READY', 'Ready...'
STOPPED = 'STATUS$STOPPED'
BUILDING_RUNTIME = 'STATUS$BUILDING_RUNTIME'
STARTING_RUNTIME = 'STATUS$STARTING_RUNTIME'
RUNTIME_STARTED = 'STATUS$RUNTIME_STARTED'
SETTING_UP_WORKSPACE = 'STATUS$SETTING_UP_WORKSPACE'
SETTING_UP_GIT_HOOKS = 'STATUS$SETTING_UP_GIT_HOOKS'
READY = 'STATUS$READY'
ERROR = 'STATUS$ERROR'
ERROR_RUNTIME_DISCONNECTED = 'STATUS$ERROR_RUNTIME_DISCONNECTED'
ERROR_LLM_AUTHENTICATION = 'STATUS$ERROR_LLM_AUTHENTICATION'
ERROR_LLM_SERVICE_UNAVAILABLE = 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE'
ERROR_LLM_INTERNAL_SERVER_ERROR = 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR'
ERROR_LLM_OUT_OF_CREDITS = 'STATUS$ERROR_LLM_OUT_OF_CREDITS'
ERROR_LLM_CONTENT_POLICY_VIOLATION = 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION'
AGENT_RATE_LIMITED_STOPPED_MESSAGE = (
'CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE'
)
GIT_PROVIDER_AUTHENTICATION_ERROR = 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR'
LLM_RETRY = 'STATUS$LLM_RETRY'
ERROR_MEMORY = 'STATUS$ERROR_MEMORY'

View File

@@ -32,6 +32,7 @@ from openhands.integrations.service_types import (
)
from openhands.llm.llm import LLM
from openhands.runtime import get_runtime_cls
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
from openhands.server.data_models.conversation_info import ConversationInfo
from openhands.server.data_models.conversation_info_result_set import (
@@ -189,7 +190,7 @@ async def new_conversation(
content={
'status': 'error',
'message': str(e),
'msg_id': 'STATUS$ERROR_LLM_AUTHENTICATION',
'msg_id': RuntimeStatus.ERROR_LLM_AUTHENTICATION.value,
},
status_code=status.HTTP_400_BAD_REQUEST,
)
@@ -199,7 +200,7 @@ async def new_conversation(
content={
'status': 'error',
'message': str(e),
'msg_id': 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR',
'msg_id': RuntimeStatus.GIT_PROVIDER_AUTHENTICATION_ERROR.value,
},
status_code=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -27,6 +27,7 @@ from openhands.microagent.microagent import BaseMicroagent
from openhands.runtime import get_runtime_cls
from openhands.runtime.base import Runtime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.security import SecurityAnalyzer, options
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.storage.files import FileStore
@@ -377,7 +378,7 @@ class AgentSession:
self.logger.error(f'Runtime initialization failed: {e}')
if self._status_callback:
self._status_callback(
'error', 'STATUS$ERROR_RUNTIME_DISCONNECTED', str(e)
'error', RuntimeStatus.ERROR_RUNTIME_DISCONNECTED, str(e)
)
return False

View File

@@ -29,6 +29,7 @@ from openhands.events.observation.error import ErrorObservation
from openhands.events.serialization import event_from_dict, event_to_dict
from openhands.events.stream import EventStreamSubscriber
from openhands.llm.llm import LLM
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.session.agent_session import AgentSession
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.storage.data_models.settings import Settings
@@ -249,9 +250,8 @@ class Session:
)
def _notify_on_llm_retry(self, retries: int, max: int) -> None:
msg_id = 'STATUS$LLM_RETRY'
self.queue_status_message(
'info', msg_id, f'Retrying LLM request, {retries} / {max}'
'info', RuntimeStatus.LLM_RETRY, f'Retrying LLM request, {retries} / {max}'
)
def on_event(self, event: Event) -> None:
@@ -337,7 +337,9 @@ class Session:
"""Sends an error message to the client."""
await self.send({'error': True, 'message': message})
async def _send_status_message(self, msg_type: str, id: str, message: str) -> None:
async def _send_status_message(
self, msg_type: str, runtime_status: RuntimeStatus, message: str
) -> None:
"""Sends a status message to the client."""
if msg_type == 'error':
agent_session = self.agent_session
@@ -349,11 +351,18 @@ class Session:
extra={'signal': 'agent_status_error'},
)
await self.send(
{'status_update': True, 'type': msg_type, 'id': id, 'message': message}
{
'status_update': True,
'type': msg_type,
'id': runtime_status.value,
'message': message,
}
)
def queue_status_message(self, msg_type: str, id: str, message: str) -> None:
def queue_status_message(
self, msg_type: str, runtime_status: RuntimeStatus, message: str
) -> None:
"""Queues a status message to be sent asynchronously."""
asyncio.run_coroutine_threadsafe(
self._send_status_message(msg_type, id, message), self.loop
self._send_status_message(msg_type, runtime_status, message), self.loop
)

View File

@@ -44,6 +44,7 @@ from openhands.runtime.base import Runtime
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.storage.memory import InMemoryFileStore
@@ -229,12 +230,15 @@ async def test_react_to_content_policy_violation(
# Verify the status callback was called with correct parameters
mock_status_callback.assert_called_once_with(
'error',
'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION',
'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION',
RuntimeStatus.ERROR_LLM_CONTENT_POLICY_VIOLATION,
RuntimeStatus.ERROR_LLM_CONTENT_POLICY_VIOLATION.value,
)
# Verify the state was updated correctly
assert controller.state.last_error == 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION'
assert (
controller.state.last_error
== RuntimeStatus.ERROR_LLM_CONTENT_POLICY_VIOLATION.value
)
assert controller.state.agent_state == AgentState.ERROR
await controller.close()
@@ -829,13 +833,15 @@ async def test_notify_on_llm_retry(mock_agent, mock_event_stream, mock_status_ca
)
def notify_on_llm_retry(attempt, max_attempts):
controller.status_callback('info', 'STATUS$LLM_RETRY', ANY)
controller.status_callback('info', RuntimeStatus.LLM_RETRY, ANY)
# Attach the retry listener to the agent's LLM
controller.agent.llm.retry_listener = notify_on_llm_retry
controller.agent.llm.retry_listener(1, 2)
controller.status_callback.assert_called_once_with('info', 'STATUS$LLM_RETRY', ANY)
controller.status_callback.assert_called_once_with(
'info', RuntimeStatus.LLM_RETRY, ANY
)
await controller.close()

View File

@@ -15,6 +15,7 @@ from openhands.integrations.service_types import (
SuggestedTask,
TaskType,
)
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.data_models.conversation_info import ConversationInfo
from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
@@ -405,7 +406,9 @@ async def test_new_conversation_invalid_session_api_key(provider_handler_mock):
assert 'Error authenticating with the LLM provider' in response.body.decode(
'utf-8'
)
assert 'STATUS$ERROR_LLM_AUTHENTICATION' in response.body.decode('utf-8')
assert RuntimeStatus.ERROR_LLM_AUTHENTICATION.value in response.body.decode(
'utf-8'
)
@pytest.mark.asyncio
@@ -575,7 +578,7 @@ async def test_new_conversation_with_provider_authentication_error(
assert json.loads(response.body.decode('utf-8')) == {
'status': 'error',
'message': 'auth error',
'msg_id': 'STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR',
'msg_id': RuntimeStatus.GIT_PROVIDER_AUTHENTICATION_ERROR.value,
}
# Verify that verify_repo_provider was called with the repository

View File

@@ -7,6 +7,7 @@ from litellm.exceptions import (
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.session.session import Session
from openhands.storage.memory import InMemoryFileStore
@@ -64,6 +65,6 @@ async def test_notify_on_llm_retry(
assert mock_litellm_completion.call_count == 2
session.queue_status_message.assert_called_once_with(
'info', 'STATUS$LLM_RETRY', ANY
'info', RuntimeStatus.LLM_RETRY, ANY
)
await session.close()