From 647416407ddbae0ab207bbbdb35d4e9f6ded28fc Mon Sep 17 00:00:00 2001 From: OpenHands Date: Mon, 16 Mar 2026 01:45:19 +0530 Subject: [PATCH 01/10] feat: Make max iteration limit resumable without reminders - Add MAX_ITERATIONS_REACHED status to ConversationExecutionStatus enum - Mark MAX_ITERATIONS_REACHED as terminal state - Add ConversationIterationLimitEvent for better error handling - Modify send_message() to reset MAX_ITERATIONS_REACHED to IDLE on new user input - Modify run() to allow restarting from MAX_ITERATIONS_REACHED state - Remove final step reminder message injection when max iterations reached - Add budget information to agent system prompt - Add budget warning messages at 80% and 95% of max iterations - Update tests to cover new status transitions This change allows conversations to resume after hitting max iterations when a new user message is sent, instead of permanently stopping. Removes the intrusive reminder message that was previously injected. --- openhands-sdk/openhands/sdk/agent/agent.py | 23 +++++-- .../conversation/impl/local_conversation.py | 63 ++++++++++++++++--- .../openhands/sdk/conversation/state.py | 4 ++ .../openhands/sdk/event/conversation_error.py | 24 +++++++ .../local/test_agent_status_transition.py | 16 ++--- ...test_conversation_execution_status_enum.py | 27 ++++++++ uv.lock | 7 ++- 7 files changed, 140 insertions(+), 24 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index 96ad54edf7..d3951625b0 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -223,6 +223,7 @@ 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: @@ -230,19 +231,29 @@ def get_dynamic_context(self, state: ConversationState) -> str | None: # 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, diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 68b0e4997a..a9f6ce39a3 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 @@ -658,6 +663,49 @@ 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: + 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." + ) + ) + ], + ), + ) + self._on_event(budget_warning) + elif 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." + ) + ) + ], + ), + ) + 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: @@ -673,17 +721,18 @@ 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, + iteration=self.max_iteration_per_run, # limit reached + 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..af6ffecafa 100644 --- a/openhands-sdk/openhands/sdk/conversation/state.py +++ b/openhands-sdk/openhands/sdk/conversation/state.py @@ -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..68c70ea697 100644 --- a/openhands-sdk/openhands/sdk/event/conversation_error.py +++ b/openhands-sdk/openhands/sdk/event/conversation_error.py @@ -35,3 +35,27 @@ 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. + """ + + iteration: int = Field(description="The iteration number when limit was reached") + max_iterations: 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"Iteration: {self.iteration}/{self.max_iterations}\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..0713485424 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 ( @@ -466,12 +467,11 @@ 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].iteration == 2 # max_iterations + assert limit_events[0].max_iterations == 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..a69e5ed778 100644 --- a/tests/sdk/conversation/test_conversation_execution_status_enum.py +++ b/tests/sdk/conversation/test_conversation_execution_status_enum.py @@ -41,6 +41,18 @@ 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 +65,9 @@ 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 +94,15 @@ 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 diff --git a/uv.lock b/uv.lock index ecab424283..394aa5acc4 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,7 @@ constraints = [ { name = "orjson", specifier = ">=3.11.7" }, { name = "pillow", specifier = ">=12.1.1" }, { name = "protobuf", specifier = ">=6.33.5" }, + { name = "rich", specifier = ">=14.3.3" }, { name = "starlette", specifier = ">=0.49.1" }, { name = "urllib3", specifier = ">=2.6.3" }, ] @@ -6337,15 +6338,15 @@ wheels = [ [[package]] name = "rich" -version = "14.2.0" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] From de5882777eb9b6e059dbea228516f7243878ddd7 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 16 Mar 2026 10:38:02 +0530 Subject: [PATCH 02/10] Remove iteration field from ConversationIterationLimitEvent and simplify budget warning logic --- .../conversation/impl/local_conversation.py | 25 +++---------------- .../openhands/sdk/event/conversation_error.py | 5 +--- .../local/test_agent_status_transition.py | 11 +++++--- uv.lock | 7 +++--- 4 files changed, 15 insertions(+), 33 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index a9f6ce39a3..385e2c05ca 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -667,26 +667,10 @@ def run(self) -> None: 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: - 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." - ) - ) - ], - ), - ) - self._on_event(budget_warning) - elif iteration == warning_95_percent: + if ( + iteration == warning_80_percent + or iteration == warning_95_percent + ): budget_warning = MessageEvent( source="environment", llm_message=Message( @@ -731,7 +715,6 @@ def run(self) -> None: self._on_event( ConversationIterationLimitEvent( source="environment", - iteration=self.max_iteration_per_run, # limit reached max_iterations=self.max_iteration_per_run, ) ) diff --git a/openhands-sdk/openhands/sdk/event/conversation_error.py b/openhands-sdk/openhands/sdk/event/conversation_error.py index 68c70ea697..e54486f0ea 100644 --- a/openhands-sdk/openhands/sdk/event/conversation_error.py +++ b/openhands-sdk/openhands/sdk/event/conversation_error.py @@ -47,7 +47,6 @@ class ConversationIterationLimitEvent(Event): different retry strategies. """ - iteration: int = Field(description="The iteration number when limit was reached") max_iterations: int = Field(description="The maximum allowed iterations") @property @@ -55,7 +54,5 @@ 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"Iteration: {self.iteration}/{self.max_iterations}\n", style="yellow" - ) + content.append(f"Max Iterations: {self.max_iterations}\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 0713485424..008438b6c7 100644 --- a/tests/sdk/conversation/local/test_agent_status_transition.py +++ b/tests/sdk/conversation/local/test_agent_status_transition.py @@ -418,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 = [] @@ -468,10 +467,14 @@ def _make_tool(conv_state=None, **params) -> Sequence[ToolDefinition]: conversation.run() # Status should be MAX_ITERATIONS_REACHED - assert conversation.state.execution_status == ConversationExecutionStatus.MAX_ITERATIONS_REACHED + assert ( + conversation.state.execution_status + == ConversationExecutionStatus.MAX_ITERATIONS_REACHED + ) # Should have emitted a ConversationIterationLimitEvent - limit_events = [e for e in events_received if isinstance(e, ConversationIterationLimitEvent)] + limit_events = [ + e for e in events_received if isinstance(e, ConversationIterationLimitEvent) + ] assert len(limit_events) == 1 - assert limit_events[0].iteration == 2 # max_iterations assert limit_events[0].max_iterations == 2 diff --git a/uv.lock b/uv.lock index 394aa5acc4..ecab424283 100644 --- a/uv.lock +++ b/uv.lock @@ -19,7 +19,6 @@ constraints = [ { name = "orjson", specifier = ">=3.11.7" }, { name = "pillow", specifier = ">=12.1.1" }, { name = "protobuf", specifier = ">=6.33.5" }, - { name = "rich", specifier = ">=14.3.3" }, { name = "starlette", specifier = ">=0.49.1" }, { name = "urllib3", specifier = ">=2.6.3" }, ] @@ -6338,15 +6337,15 @@ wheels = [ [[package]] name = "rich" -version = "14.3.3" +version = "14.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] [[package]] From 18e11be53e1eaca907d3aab691463f0cbc2af574 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 16 Mar 2026 11:03:08 +0530 Subject: [PATCH 03/10] Update test files and uv.lock Co-authored-by: openhands --- .../test_conversation_execution_status_enum.py | 17 +++++++++++++---- .../test_prompt_caching_cross_conversation.py | 2 +- uv.lock | 7 ++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/tests/sdk/conversation/test_conversation_execution_status_enum.py b/tests/sdk/conversation/test_conversation_execution_status_enum.py index a69e5ed778..79f643e968 100644 --- a/tests/sdk/conversation/test_conversation_execution_status_enum.py +++ b/tests/sdk/conversation/test_conversation_execution_status_enum.py @@ -46,8 +46,13 @@ def test_agent_execution_state_enum_basic(): 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 + 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 @@ -66,7 +71,9 @@ 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.MAX_ITERATIONS_REACHED == "max_iterations_reached" + ) assert ConversationExecutionStatus.DELETING == "deleting" @@ -99,7 +106,9 @@ def test_enum_serialization(): serialized = conversation._state.model_dump_json() assert '"execution_status":"stuck"' in serialized - conversation._state.execution_status = ConversationExecutionStatus.MAX_ITERATIONS_REACHED + conversation._state.execution_status = ( + ConversationExecutionStatus.MAX_ITERATIONS_REACHED + ) serialized = conversation._state.model_dump_json() assert '"execution_status":"max_iterations_reached"' in serialized diff --git a/tests/sdk/llm/test_prompt_caching_cross_conversation.py b/tests/sdk/llm/test_prompt_caching_cross_conversation.py index a2ec3f9699..50581e6e71 100644 --- a/tests/sdk/llm/test_prompt_caching_cross_conversation.py +++ b/tests/sdk/llm/test_prompt_caching_cross_conversation.py @@ -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): diff --git a/uv.lock b/uv.lock index ecab424283..394aa5acc4 100644 --- a/uv.lock +++ b/uv.lock @@ -19,6 +19,7 @@ constraints = [ { name = "orjson", specifier = ">=3.11.7" }, { name = "pillow", specifier = ">=12.1.1" }, { name = "protobuf", specifier = ">=6.33.5" }, + { name = "rich", specifier = ">=14.3.3" }, { name = "starlette", specifier = ">=0.49.1" }, { name = "urllib3", specifier = ">=2.6.3" }, ] @@ -6337,15 +6338,15 @@ wheels = [ [[package]] name = "rich" -version = "14.2.0" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] From edd9c8fd7fbb98754512fd51fd0f7e2ac12bdadd Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 16 Mar 2026 22:55:39 +0530 Subject: [PATCH 04/10] Bump version to 1.15.0 for REST API breaking changes Co-authored-by: openhands --- openhands-agent-server/pyproject.toml | 2 +- openhands-sdk/pyproject.toml | 2 +- openhands-tools/pyproject.toml | 2 +- openhands-workspace/pyproject.toml | 2 +- uv.lock | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index c7573f4b6f..5d5b2ea6c0 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-agent-server" -version = "1.14.0" +version = "1.15.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" requires-python = ">=3.12" diff --git a/openhands-sdk/pyproject.toml b/openhands-sdk/pyproject.toml index 248921c6e2..6eec53bf9b 100644 --- a/openhands-sdk/pyproject.toml +++ b/openhands-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-sdk" -version = "1.14.0" +version = "1.15.0" description = "OpenHands SDK - Core functionality for building AI agents" requires-python = ">=3.12" diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index 3677f4c6f6..410ad98638 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-tools" -version = "1.14.0" +version = "1.15.0" description = "OpenHands Tools - Runtime tools for AI agents" requires-python = ">=3.12" diff --git a/openhands-workspace/pyproject.toml b/openhands-workspace/pyproject.toml index 1d4a314d64..ce2747250b 100644 --- a/openhands-workspace/pyproject.toml +++ b/openhands-workspace/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-workspace" -version = "1.14.0" +version = "1.15.0" description = "OpenHands Workspace - Docker and container-based workspace implementations" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 6fce0cbd5e..b3ba3b2d73 100644 --- a/uv.lock +++ b/uv.lock @@ -2341,7 +2341,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.14.0" +version = "1.15.0" source = { editable = "openhands-agent-server" } dependencies = [ { name = "aiosqlite" }, @@ -2372,7 +2372,7 @@ requires-dist = [ [[package]] name = "openhands-sdk" -version = "1.14.0" +version = "1.15.0" source = { editable = "openhands-sdk" } dependencies = [ { name = "agent-client-protocol" }, @@ -2416,7 +2416,7 @@ provides-extras = ["boto3"] [[package]] name = "openhands-tools" -version = "1.14.0" +version = "1.15.0" source = { editable = "openhands-tools" } dependencies = [ { name = "bashlex" }, @@ -2445,7 +2445,7 @@ requires-dist = [ [[package]] name = "openhands-workspace" -version = "1.14.0" +version = "1.15.0" source = { editable = "openhands-workspace" } dependencies = [ { name = "openhands-agent-server" }, From 37e697b39e550774cf4aa256faa6c5de7a43617b Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Mon, 16 Mar 2026 22:58:08 +0530 Subject: [PATCH 05/10] Make max_iteration warning informative only Remove nudge to wrap up task from budget warning message. The warning now only informs the agent about remaining steps without suggesting they should complete the task. Co-authored-by: openhands --- .../openhands/sdk/conversation/impl/local_conversation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index a17b4274bc..b105aa140c 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -681,8 +681,7 @@ def run(self) -> None: 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." + "steps remaining." ) ) ], From d0f2706d4e145d8daa9d499c639c3f7cbae13081 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Wed, 18 Mar 2026 18:31:19 +0530 Subject: [PATCH 06/10] Remove budget reminders and reverse version bump - Remove 80% and 95% budget warning messages from conversation execution - Remove budget info from system prompt dynamic context - Update prompt caching test to expect no dynamic context when no agent context - Revert all package versions from 1.15.0 back to 1.14.0 - Update uv.lock accordingly Co-authored-by: openhands --- openhands-agent-server/pyproject.toml | 2 +- openhands-sdk/openhands/sdk/agent/agent.py | 11 +------- .../conversation/impl/local_conversation.py | 26 ------------------- openhands-sdk/pyproject.toml | 2 +- openhands-tools/pyproject.toml | 2 +- openhands-workspace/pyproject.toml | 2 +- .../test_prompt_caching_cross_conversation.py | 2 +- uv.lock | 8 +++--- 8 files changed, 10 insertions(+), 45 deletions(-) diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index 5d5b2ea6c0..c7573f4b6f 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-agent-server" -version = "1.15.0" +version = "1.14.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" requires-python = ">=3.12" diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index af44005cbf..a8fbf69465 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -253,16 +253,7 @@ def get_dynamic_context(self, state: ConversationState) -> str | None: 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 + return base_context def _execute_actions( self, diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index bb6ad8f711..97ef3b2c13 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -663,32 +663,6 @@ 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." - ) - ) - ], - ), - ) - 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: diff --git a/openhands-sdk/pyproject.toml b/openhands-sdk/pyproject.toml index 6eec53bf9b..248921c6e2 100644 --- a/openhands-sdk/pyproject.toml +++ b/openhands-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-sdk" -version = "1.15.0" +version = "1.14.0" description = "OpenHands SDK - Core functionality for building AI agents" requires-python = ">=3.12" diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index 410ad98638..3677f4c6f6 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-tools" -version = "1.15.0" +version = "1.14.0" description = "OpenHands Tools - Runtime tools for AI agents" requires-python = ">=3.12" diff --git a/openhands-workspace/pyproject.toml b/openhands-workspace/pyproject.toml index ce2747250b..1d4a314d64 100644 --- a/openhands-workspace/pyproject.toml +++ b/openhands-workspace/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-workspace" -version = "1.15.0" +version = "1.14.0" description = "OpenHands Workspace - Docker and container-based workspace implementations" requires-python = ">=3.12" diff --git a/tests/sdk/llm/test_prompt_caching_cross_conversation.py b/tests/sdk/llm/test_prompt_caching_cross_conversation.py index 50581e6e71..a2ec3f9699 100644 --- a/tests/sdk/llm/test_prompt_caching_cross_conversation.py +++ b/tests/sdk/llm/test_prompt_caching_cross_conversation.py @@ -66,7 +66,7 @@ def test_static_system_message_is_constant_across_different_contexts(): ("dynamic_context", "expect_dynamic"), [ (TextContent(text="Dynamic context"), True), - (None, True), + (None, False), ], ) def test_end_to_end_caching_flow(tmp_path, dynamic_context, expect_dynamic): diff --git a/uv.lock b/uv.lock index 97a615e3cb..a40499da9e 100644 --- a/uv.lock +++ b/uv.lock @@ -2341,7 +2341,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.15.0" +version = "1.14.0" source = { editable = "openhands-agent-server" } dependencies = [ { name = "aiosqlite" }, @@ -2372,7 +2372,7 @@ requires-dist = [ [[package]] name = "openhands-sdk" -version = "1.15.0" +version = "1.14.0" source = { editable = "openhands-sdk" } dependencies = [ { name = "agent-client-protocol" }, @@ -2416,7 +2416,7 @@ provides-extras = ["boto3"] [[package]] name = "openhands-tools" -version = "1.15.0" +version = "1.14.0" source = { editable = "openhands-tools" } dependencies = [ { name = "bashlex" }, @@ -2445,7 +2445,7 @@ requires-dist = [ [[package]] name = "openhands-workspace" -version = "1.15.0" +version = "1.14.0" source = { editable = "openhands-workspace" } dependencies = [ { name = "openhands-agent-server" }, From 05d60e0686d63fe3828fd977134bf8eb00498a52 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Wed, 18 Mar 2026 18:39:18 +0530 Subject: [PATCH 07/10] cleanup agent.py --- openhands-sdk/openhands/sdk/agent/agent.py | 34 +++++++--------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index a8fbf69465..96ad54edf7 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -80,21 +80,11 @@ class Agent(CriticMixin, AgentBase): AgentBase and implements the agent execution logic. Critic-related functionality is provided by CriticMixin. - Attributes: - llm: The language model instance used for reasoning. - tools: List of tools available to the agent. - name: Optional agent identifier. - system_prompt: Custom system prompt (uses default if not provided). - Example: - ```python - from openhands.sdk import LLM, Agent, Tool - from pydantic import SecretStr - - llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key")) - tools = [Tool(name="TerminalTool"), Tool(name="FileEditorTool")] - agent = Agent(llm=llm, tools=tools) - ``` + >>> from openhands.sdk import LLM, Agent, Tool + >>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key")) + >>> tools = [Tool(name="TerminalTool"), Tool(name="FileEditorTool")] + >>> agent = Agent(llm=llm, tools=tools) """ @model_validator(mode="before") @@ -233,7 +223,6 @@ 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: @@ -241,19 +230,18 @@ def get_dynamic_context(self, state: ConversationState) -> str | None: # Create a minimal context just for secrets temp_context = AgentContext() - base_context = temp_context.get_system_message_suffix( + return temp_context.get_system_message_suffix( llm_model=self.llm.model, llm_model_canonical=self.llm.model_canonical_name, additional_secret_infos=secret_infos, ) - 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 None - return base_context + 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, + ) def _execute_actions( self, From 765f912f18dc2a9e8a395885354443f3ba528c68 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Thu, 19 Mar 2026 05:40:56 +0530 Subject: [PATCH 08/10] refactor: use StrEnum for ConversationExecutionStatus and rename max_iterations to max_iteration_per_run - Changed ConversationExecutionStatus to inherit from StrEnum instead of str, Enum - Renamed max_iterations field to max_iteration_per_run in ConversationIterationLimitEvent - Updated corresponding test assertion to use the new field name Co-authored-by: openhands --- openhands-sdk/openhands/sdk/conversation/state.py | 4 ++-- openhands-sdk/openhands/sdk/event/conversation_error.py | 6 ++++-- .../sdk/conversation/local/test_agent_status_transition.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/state.py b/openhands-sdk/openhands/sdk/conversation/state.py index af6ffecafa..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 diff --git a/openhands-sdk/openhands/sdk/event/conversation_error.py b/openhands-sdk/openhands/sdk/event/conversation_error.py index e54486f0ea..c45d2f85b1 100644 --- a/openhands-sdk/openhands/sdk/event/conversation_error.py +++ b/openhands-sdk/openhands/sdk/event/conversation_error.py @@ -47,12 +47,14 @@ class ConversationIterationLimitEvent(Event): different retry strategies. """ - max_iterations: int = Field(description="The maximum allowed iterations") + 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_iterations}\n", style="yellow") + 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 008438b6c7..3d53e573fc 100644 --- a/tests/sdk/conversation/local/test_agent_status_transition.py +++ b/tests/sdk/conversation/local/test_agent_status_transition.py @@ -477,4 +477,4 @@ def _make_tool(conv_state=None, **params) -> Sequence[ToolDefinition]: e for e in events_received if isinstance(e, ConversationIterationLimitEvent) ] assert len(limit_events) == 1 - assert limit_events[0].max_iterations == 2 + assert limit_events[0].max_iteration_per_run == 2 From c0a3f9b86e7c67ae1165b513c9973d1443ec0109 Mon Sep 17 00:00:00 2001 From: Utkarsh Date: Thu, 19 Mar 2026 05:47:02 +0530 Subject: [PATCH 09/10] Update agent.py Co-authored-by: openhands --- openhands-sdk/openhands/sdk/agent/agent.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/agent.py b/openhands-sdk/openhands/sdk/agent/agent.py index 96ad54edf7..bd85e44182 100644 --- a/openhands-sdk/openhands/sdk/agent/agent.py +++ b/openhands-sdk/openhands/sdk/agent/agent.py @@ -80,11 +80,21 @@ class Agent(CriticMixin, AgentBase): AgentBase and implements the agent execution logic. Critic-related functionality is provided by CriticMixin. + Attributes: + llm: The language model instance used for reasoning. + tools: List of tools available to the agent. + name: Optional agent identifier. + system_prompt: Custom system prompt (uses default if not provided). + Example: - >>> from openhands.sdk import LLM, Agent, Tool - >>> llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key")) - >>> tools = [Tool(name="TerminalTool"), Tool(name="FileEditorTool")] - >>> agent = Agent(llm=llm, tools=tools) + ```python + from openhands.sdk import LLM, Agent, Tool + from pydantic import SecretStr + + llm = LLM(model="claude-sonnet-4-20250514", api_key=SecretStr("key")) + tools = [Tool(name="TerminalTool"), Tool(name="FileEditorTool")] + agent = Agent(llm=llm, tools=tools) + ``` """ @model_validator(mode="before") From 86b5799feabeddc9deaeaa8fe7abb7afb127abc8 Mon Sep 17 00:00:00 2001 From: enyst Date: Tue, 31 Mar 2026 17:04:30 +0000 Subject: [PATCH 10/10] fix(sdk): correct iteration limit event field Co-authored-by: openhands --- .../openhands/sdk/conversation/impl/local_conversation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 9c56b498c4..1c1fe20296 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -692,7 +692,7 @@ def run(self) -> None: self._on_event( ConversationIterationLimitEvent( source="environment", - max_iterations=self.max_iteration_per_run, + max_iteration_per_run=self.max_iteration_per_run, ) ) break