diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 68b0e4997a..97ef3b2c13 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -33,7 +33,10 @@ PauseEvent, UserRejectObservation, ) -from openhands.sdk.event.conversation_error import ConversationErrorEvent +from openhands.sdk.event.conversation_error import ( + ConversationErrorEvent, + ConversationIterationLimitEvent, +) from openhands.sdk.hooks import HookConfig, HookEventProcessor, create_hook_callback from openhands.sdk.io import LocalFileStore from openhands.sdk.llm import LLM, Message, TextContent @@ -529,6 +532,7 @@ def send_message(self, message: str | Message, sender: str | None = None) -> Non if self._state.execution_status in ( ConversationExecutionStatus.FINISHED, ConversationExecutionStatus.STUCK, + ConversationExecutionStatus.MAX_ITERATIONS_REACHED, ): self._state.execution_status = ( ConversationExecutionStatus.IDLE @@ -588,6 +592,7 @@ def run(self) -> None: ConversationExecutionStatus.PAUSED, ConversationExecutionStatus.ERROR, ConversationExecutionStatus.STUCK, + ConversationExecutionStatus.MAX_ITERATIONS_REACHED, ]: self._state.execution_status = ConversationExecutionStatus.RUNNING @@ -673,17 +678,17 @@ def run(self) -> None: break if iteration >= self.max_iteration_per_run: - error_msg = ( + logger.error( f"Agent reached maximum iterations limit " f"({self.max_iteration_per_run})." ) - logger.error(error_msg) - self._state.execution_status = ConversationExecutionStatus.ERROR + self._state.execution_status = ( + ConversationExecutionStatus.MAX_ITERATIONS_REACHED + ) self._on_event( - ConversationErrorEvent( + ConversationIterationLimitEvent( source="environment", - code="MaxIterationsReached", - detail=error_msg, + max_iterations=self.max_iteration_per_run, ) ) break diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py index 2f396dc116..df6b82f7d4 100644 --- a/openhands-sdk/openhands/sdk/conversation/state.py +++ b/openhands-sdk/openhands/sdk/conversation/state.py @@ -1,7 +1,7 @@ # state.py import json from collections.abc import Sequence -from enum import Enum +from enum import StrEnum from pathlib import Path from typing import Any, Self @@ -39,7 +39,7 @@ logger = get_logger(__name__) -class ConversationExecutionStatus(str, Enum): +class ConversationExecutionStatus(StrEnum): """Enum representing the current execution state of the conversation.""" IDLE = "idle" # Conversation is ready to receive tasks @@ -51,6 +51,9 @@ class ConversationExecutionStatus(str, Enum): FINISHED = "finished" # Conversation has completed the current task ERROR = "error" # Conversation encountered an error (optional for future use) STUCK = "stuck" # Conversation is stuck in a loop or unable to proceed + MAX_ITERATIONS_REACHED = ( + "max_iterations_reached" # Conversation reached maximum iteration limit + ) DELETING = "deleting" # Conversation is in the process of being deleted def is_terminal(self) -> bool: @@ -70,6 +73,7 @@ def is_terminal(self) -> bool: ConversationExecutionStatus.FINISHED, ConversationExecutionStatus.ERROR, ConversationExecutionStatus.STUCK, + ConversationExecutionStatus.MAX_ITERATIONS_REACHED, ) diff --git a/openhands-sdk/openhands/sdk/event/conversation_error.py b/openhands-sdk/openhands/sdk/event/conversation_error.py index 499d727e98..c45d2f85b1 100644 --- a/openhands-sdk/openhands/sdk/event/conversation_error.py +++ b/openhands-sdk/openhands/sdk/event/conversation_error.py @@ -35,3 +35,26 @@ def visualize(self) -> Text: content.append("\n\nDetail:\n", style="bold") content.append(self.detail) return content + + +class ConversationIterationLimitEvent(Event): + """ + Event emitted when a conversation reaches its maximum iteration limit. + + This is a terminal event that indicates the agent has exhausted its + allocated iterations without completing the task. It allows clients to + distinguish between actual errors and budget exhaustion, enabling + different retry strategies. + """ + + max_iteration_per_run: int = Field(description="The maximum allowed iterations") + + @property + def visualize(self) -> Text: + """Return Rich Text representation of this iteration limit event.""" + content = Text() + content.append("Iteration Limit Reached\n", style="bold") + content.append( + f"Max Iterations: {self.max_iteration_per_run}\n", style="yellow" + ) + return content diff --git a/tests/sdk/conversation/local/test_agent_status_transition.py b/tests/sdk/conversation/local/test_agent_status_transition.py index 6f8d63ab4f..3d53e573fc 100644 --- a/tests/sdk/conversation/local/test_agent_status_transition.py +++ b/tests/sdk/conversation/local/test_agent_status_transition.py @@ -25,6 +25,7 @@ from openhands.sdk.conversation import Conversation from openhands.sdk.conversation.state import ConversationExecutionStatus from openhands.sdk.event import MessageEvent +from openhands.sdk.event.conversation_error import ConversationIterationLimitEvent from openhands.sdk.llm import ImageContent, Message, MessageToolCall, TextContent from openhands.sdk.testing import TestLLM from openhands.sdk.tool import ( @@ -417,7 +418,6 @@ def test_send_message_resets_stuck_to_idle(): def test_execution_status_error_on_max_iterations(): """Test that status is set to ERROR with clear message when max iterations hit.""" - from openhands.sdk.event.conversation_error import ConversationErrorEvent status_during_execution: list[ConversationExecutionStatus] = [] events_received: list = [] @@ -466,12 +466,15 @@ def _make_tool(conv_state=None, **params) -> Sequence[ToolDefinition]: ) conversation.run() - # Status should be ERROR - assert conversation.state.execution_status == ConversationExecutionStatus.ERROR + # Status should be MAX_ITERATIONS_REACHED + assert ( + conversation.state.execution_status + == ConversationExecutionStatus.MAX_ITERATIONS_REACHED + ) - # Should have emitted a ConversationErrorEvent with clear message - error_events = [e for e in events_received if isinstance(e, ConversationErrorEvent)] - assert len(error_events) == 1 - assert error_events[0].code == "MaxIterationsReached" - assert "maximum iterations limit" in error_events[0].detail - assert "(2)" in error_events[0].detail # max_iteration_per_run value + # Should have emitted a ConversationIterationLimitEvent + limit_events = [ + e for e in events_received if isinstance(e, ConversationIterationLimitEvent) + ] + assert len(limit_events) == 1 + assert limit_events[0].max_iteration_per_run == 2 diff --git a/tests/sdk/conversation/test_conversation_execution_status_enum.py b/tests/sdk/conversation/test_conversation_execution_status_enum.py index 93255e373b..79f643e968 100644 --- a/tests/sdk/conversation/test_conversation_execution_status_enum.py +++ b/tests/sdk/conversation/test_conversation_execution_status_enum.py @@ -41,6 +41,23 @@ def test_agent_execution_state_enum_basic(): conversation._state.execution_status = ConversationExecutionStatus.ERROR assert conversation._state.execution_status == ConversationExecutionStatus.ERROR + # Test setting to STUCK + conversation._state.execution_status = ConversationExecutionStatus.STUCK + assert conversation._state.execution_status == ConversationExecutionStatus.STUCK + + # Test setting to MAX_ITERATIONS_REACHED + conversation._state.execution_status = ( + ConversationExecutionStatus.MAX_ITERATIONS_REACHED + ) + assert ( + conversation._state.execution_status + == ConversationExecutionStatus.MAX_ITERATIONS_REACHED + ) + + # Test setting to DELETING + conversation._state.execution_status = ConversationExecutionStatus.DELETING + assert conversation._state.execution_status == ConversationExecutionStatus.DELETING + def test_enum_values(): """Test that all enum values are correct.""" @@ -53,6 +70,11 @@ def test_enum_values(): ) assert ConversationExecutionStatus.FINISHED == "finished" assert ConversationExecutionStatus.ERROR == "error" + assert ConversationExecutionStatus.STUCK == "stuck" + assert ( + ConversationExecutionStatus.MAX_ITERATIONS_REACHED == "max_iterations_reached" + ) + assert ConversationExecutionStatus.DELETING == "deleting" def test_enum_serialization(): @@ -79,3 +101,17 @@ def test_enum_serialization(): conversation._state.execution_status = ConversationExecutionStatus.ERROR serialized = conversation._state.model_dump_json() assert '"execution_status":"error"' in serialized + + conversation._state.execution_status = ConversationExecutionStatus.STUCK + serialized = conversation._state.model_dump_json() + assert '"execution_status":"stuck"' in serialized + + conversation._state.execution_status = ( + ConversationExecutionStatus.MAX_ITERATIONS_REACHED + ) + serialized = conversation._state.model_dump_json() + assert '"execution_status":"max_iterations_reached"' in serialized + + conversation._state.execution_status = ConversationExecutionStatus.DELETING + serialized = conversation._state.model_dump_json() + assert '"execution_status":"deleting"' in serialized