Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -588,6 +592,7 @@ def run(self) -> None:
ConversationExecutionStatus.PAUSED,
ConversationExecutionStatus.ERROR,
ConversationExecutionStatus.STUCK,
ConversationExecutionStatus.MAX_ITERATIONS_REACHED,
]:
self._state.execution_status = ConversationExecutionStatus.RUNNING

Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions openhands-sdk/openhands/sdk/conversation/state.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -70,6 +73,7 @@ def is_terminal(self) -> bool:
ConversationExecutionStatus.FINISHED,
ConversationExecutionStatus.ERROR,
ConversationExecutionStatus.STUCK,
ConversationExecutionStatus.MAX_ITERATIONS_REACHED,
)


Expand Down
23 changes: 23 additions & 0 deletions openhands-sdk/openhands/sdk/event/conversation_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 12 additions & 9 deletions tests/sdk/conversation/local/test_agent_status_transition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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
36 changes: 36 additions & 0 deletions tests/sdk/conversation/test_conversation_execution_status_enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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():
Expand All @@ -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