Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
23 changes: 17 additions & 6 deletions openhands-sdk/openhands/sdk/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,26 +223,37 @@ def get_dynamic_context(self, state: ConversationState) -> str | None:
# Get secret infos from conversation's secret_registry
secret_infos = state.secret_registry.get_secret_infos()

base_context = None
if not self.agent_context:
# No agent_context but we might have secrets from registry
if secret_infos:
from openhands.sdk.context.agent_context import AgentContext

# Create a minimal context just for secrets
temp_context = AgentContext()
return temp_context.get_system_message_suffix(
base_context = temp_context.get_system_message_suffix(
llm_model=self.llm.model,
llm_model_canonical=self.llm.model_canonical_name,
additional_secret_infos=secret_infos,
)
return None
else:
base_context = self.agent_context.get_system_message_suffix(
llm_model=self.llm.model,
llm_model_canonical=self.llm.model_canonical_name,
additional_secret_infos=secret_infos,
)

return self.agent_context.get_system_message_suffix(
llm_model=self.llm.model,
llm_model_canonical=self.llm.model_canonical_name,
additional_secret_infos=secret_infos,
# Add budget information to the dynamic context (Option A)
budget_info = (
f"\n\nYou have a budget of {state.max_iterations} steps for this task. "
"Plan your approach to complete the task within this budget."
)

if base_context:
return base_context + budget_info
else:
return budget_info

def _execute_actions(
self,
conversation: LocalConversation,
Expand Down
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 @@ -658,6 +663,33 @@ def run(self) -> None:
)
iteration += 1

# Inject budget warnings (Option A)
warning_80_percent = int(self.max_iteration_per_run * 0.8)
warning_95_percent = int(self.max_iteration_per_run * 0.95)

if (
iteration == warning_80_percent
or iteration == warning_95_percent
):
budget_warning = MessageEvent(
source="environment",
llm_message=Message(
role="user",
content=[
TextContent(
text=(
f"[SYSTEM] You have used {iteration}/"
f"{self.max_iteration_per_run} steps. "
f"{self.max_iteration_per_run - iteration} "
"steps remaining. Begin wrapping up and "
"provide your best answer."
Comment thread
enyst marked this conversation as resolved.
Outdated
)
)
],
),
)
self._on_event(budget_warning)

# Check for non-finished terminal conditions
# Note: We intentionally do NOT check for FINISHED status here.
# This allows concurrent user messages to be processed:
Expand All @@ -673,17 +705,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
4 changes: 4 additions & 0 deletions openhands-sdk/openhands/sdk/conversation/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 21 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,24 @@ 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_iterations: int = Field(description="The maximum allowed iterations")
Comment thread
VascoSch92 marked this conversation as resolved.
Outdated

@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_iterations}\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_iterations == 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
2 changes: 1 addition & 1 deletion tests/sdk/llm/test_prompt_caching_cross_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def test_static_system_message_is_constant_across_different_contexts():
("dynamic_context", "expect_dynamic"),
[
(TextContent(text="Dynamic context"), True),
(None, False),
(None, True),
],
)
def test_end_to_end_caching_flow(tmp_path, dynamic_context, expect_dynamic):
Expand Down
7 changes: 4 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading