From ff4bc166465937e493f945daa8345751e2bd3707 Mon Sep 17 00:00:00 2001 From: Co1lin Date: Wed, 7 Jan 2026 03:23:54 +0000 Subject: [PATCH 1/5] fix: increase read timeout and max connections limit of httpx client used by remoteworkspace --- openhands-sdk/openhands/sdk/workspace/remote/base.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index 8de5bde5f4..e120d3a0cd 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -52,9 +52,12 @@ def client(self) -> httpx.Client: # - read: 60 seconds to read response (for LLM operations) # - write: 10 seconds to send request # - pool: 10 seconds to get connection from pool - timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0) + timeout = httpx.Timeout(connect=10.0, read=600.0, write=10.0, pool=10.0) client = httpx.Client( - base_url=self.host, timeout=timeout, headers=self._headers + base_url=self.host, + timeout=timeout, + headers=self._headers, + limits=httpx.Limits(max_connections=None), ) self._client = client return client From 236ed332188b09b8dad0c6e5628ca96fa06734f9 Mon Sep 17 00:00:00 2001 From: Co1lin Date: Wed, 7 Jan 2026 03:50:26 +0000 Subject: [PATCH 2/5] fix: release tmux, prevent too many open files --- .../sdk/conversation/impl/remote_conversation.py | 8 ++++++++ .../openhands/workspace/docker/workspace.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index e2243719aa..2a1cd61da7 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -984,6 +984,14 @@ def close(self) -> None: if self._hook_processor is not None: self._hook_processor.run_session_end() try: + # trigger server-side delete_conversation to release resources + # like tmux sessions + _send_request( + self._client, + "DELETE", + f"/api/conversations/{self.id}", + acceptable_status_codes={200, 204}, + ) # Stop WebSocket client if it exists if self._ws_client: self._ws_client.stop() diff --git a/openhands-workspace/openhands/workspace/docker/workspace.py b/openhands-workspace/openhands/workspace/docker/workspace.py index f93f863f02..aa588ddacb 100644 --- a/openhands-workspace/openhands/workspace/docker/workspace.py +++ b/openhands-workspace/openhands/workspace/docker/workspace.py @@ -221,6 +221,8 @@ def _start_container(self, image: str, context: Any) -> None: "--platform", self.platform, "--rm", + "--ulimit", + "nofile=65536:65536", # prevent "too many open files" errors "--name", f"agent-server-{uuid.uuid4()}", *flags, From cc0063be4de6389e590698ee2a54da7c95f96cae Mon Sep 17 00:00:00 2001 From: Co1lin Date: Wed, 7 Jan 2026 04:25:43 +0000 Subject: [PATCH 3/5] fix: comments on read timeout --- openhands-sdk/openhands/sdk/workspace/remote/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index e120d3a0cd..4418fef3a7 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -49,7 +49,7 @@ def client(self) -> httpx.Client: if client is None: # Configure reasonable timeouts for HTTP requests # - connect: 10 seconds to establish connection - # - read: 60 seconds to read response (for LLM operations) + # - read: 600 seconds to read response (for LLM operations) # - write: 10 seconds to send request # - pool: 10 seconds to get connection from pool timeout = httpx.Timeout(connect=10.0, read=600.0, write=10.0, pool=10.0) From dd3b07c9fea5a9d988ccff361674017d5b90e5b0 Mon Sep 17 00:00:00 2001 From: Co1lin Date: Fri, 9 Jan 2026 20:02:35 +0000 Subject: [PATCH 4/5] refactor: add read_timeout attribute --- openhands-sdk/openhands/sdk/agent/base.py | 2 +- openhands-sdk/openhands/sdk/workspace/remote/base.py | 4 +++- .../openhands/sdk/workspace/remote/remote_workspace_mixin.py | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openhands-sdk/openhands/sdk/agent/base.py b/openhands-sdk/openhands/sdk/agent/base.py index 5d65cc090c..eb495374a9 100644 --- a/openhands-sdk/openhands/sdk/agent/base.py +++ b/openhands-sdk/openhands/sdk/agent/base.py @@ -466,5 +466,5 @@ def tools_map(self) -> dict[str, ToolDefinition]: RuntimeError: If the agent has not been initialized. """ if not self._tools: - raise RuntimeError("Agent not initialized; call initialize() before use") + raise RuntimeError("Agent not initialized; call _initialize() before use") return self._tools diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index 4418fef3a7..445ed161be 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -52,7 +52,9 @@ def client(self) -> httpx.Client: # - read: 600 seconds to read response (for LLM operations) # - write: 10 seconds to send request # - pool: 10 seconds to get connection from pool - timeout = httpx.Timeout(connect=10.0, read=600.0, write=10.0, pool=10.0) + timeout = httpx.Timeout( + connect=10.0, read=self.read_timeout, write=10.0, pool=10.0 + ) client = httpx.Client( base_url=self.host, timeout=timeout, diff --git a/openhands-sdk/openhands/sdk/workspace/remote/remote_workspace_mixin.py b/openhands-sdk/openhands/sdk/workspace/remote/remote_workspace_mixin.py index 3338af73b6..d40660f882 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/remote_workspace_mixin.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/remote_workspace_mixin.py @@ -25,6 +25,10 @@ class RemoteWorkspaceMixin(BaseModel): working_dir: str = Field( description="The working directory for agent operations and tool execution." ) + read_timeout: float = Field( + default=600.0, + description="Timeout in seconds for reading operations of httpx.Client.", + ) def model_post_init(self, context: Any) -> None: # Set up remote host From f49c409184d6f637661c594779943f6009ca36fb Mon Sep 17 00:00:00 2001 From: Co1lin Date: Fri, 9 Jan 2026 20:21:41 +0000 Subject: [PATCH 5/5] feat: stop_agent_on_close --- .../sdk/conversation/conversation.py | 5 +++ .../conversation/impl/local_conversation.py | 32 +++++++++++-------- .../conversation/impl/remote_conversation.py | 18 ++++++----- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/openhands-sdk/openhands/sdk/conversation/conversation.py b/openhands-sdk/openhands/sdk/conversation/conversation.py index 46b2a70fdf..8b45ff5055 100644 --- a/openhands-sdk/openhands/sdk/conversation/conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/conversation.py @@ -67,6 +67,7 @@ def __new__( type[ConversationVisualizerBase] | ConversationVisualizerBase | None ) = DefaultConversationVisualizer, secrets: dict[str, SecretValue] | dict[str, str] | None = None, + stop_agent_on_close: bool = False, ) -> "LocalConversation": ... @overload @@ -88,6 +89,7 @@ def __new__( type[ConversationVisualizerBase] | ConversationVisualizerBase | None ) = DefaultConversationVisualizer, secrets: dict[str, SecretValue] | dict[str, str] | None = None, + stop_agent_on_close: bool = False, ) -> "RemoteConversation": ... def __new__( @@ -109,6 +111,7 @@ def __new__( type[ConversationVisualizerBase] | ConversationVisualizerBase | None ) = DefaultConversationVisualizer, secrets: dict[str, SecretValue] | dict[str, str] | None = None, + stop_agent_on_close: bool = False, ) -> BaseConversation: from openhands.sdk.conversation.impl.local_conversation import LocalConversation from openhands.sdk.conversation.impl.remote_conversation import ( @@ -134,6 +137,7 @@ def __new__( visualizer=visualizer, workspace=workspace, secrets=secrets, + stop_agent_on_close=stop_agent_on_close, ) return LocalConversation( @@ -149,4 +153,5 @@ def __new__( workspace=workspace, persistence_dir=persistence_dir, secrets=secrets, + stop_agent_on_close=stop_agent_on_close, ) diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 9da8fa681a..09d6f27add 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -58,6 +58,7 @@ class LocalConversation(BaseConversation): llm_registry: LLMRegistry _cleanup_initiated: bool _hook_processor: HookEventProcessor | None + stop_agent_on_close: bool = False def __init__( self, @@ -77,6 +78,7 @@ def __init__( type[ConversationVisualizerBase] | ConversationVisualizerBase | None ) = DefaultConversationVisualizer, secrets: Mapping[str, SecretValue] | None = None, + stop_agent_on_close: bool = False, **_: object, ): """Initialize the conversation. @@ -222,6 +224,7 @@ def _default_callback(e): atexit.register(self.close) self._start_observability_span(str(desired_id)) + self.stop_agent_on_close = stop_agent_on_close @property def id(self) -> ConversationID: @@ -535,20 +538,23 @@ def close(self) -> None: except AttributeError: # Object may be partially constructed; span fields may be missing. pass - try: - tools_map = self.agent.tools_map - except (AttributeError, RuntimeError): - # Agent not initialized or partially constructed - return - for tool in tools_map.values(): + if self.stop_agent_on_close: try: - executable_tool = tool.as_executable() - executable_tool.executor.close() - except NotImplementedError: - # Tool has no executor, skip it without erroring - continue - except Exception as e: - logger.warning(f"Error closing executor for tool '{tool.name}': {e}") + tools_map = self.agent.tools_map + except (AttributeError, RuntimeError): + # Agent not initialized or partially constructed + return + for tool in tools_map.values(): + try: + executable_tool = tool.as_executable() + executable_tool.executor.close() + except NotImplementedError: + # Tool has no executor, skip it without erroring + continue + except Exception as e: + logger.warning( + f"Error closing executor for tool '{tool.name}': {e}" + ) def ask_agent(self, question: str) -> str: """Ask the agent a simple, stateless question and get a direct LLM response. diff --git a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py index 2a1cd61da7..391934f316 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/remote_conversation.py @@ -439,6 +439,7 @@ class RemoteConversation(BaseConversation): _client: httpx.Client _hook_processor: HookEventProcessor | None _cleanup_initiated: bool + stop_agent_on_close: bool = False def __init__( self, @@ -456,6 +457,7 @@ def __init__( type[ConversationVisualizerBase] | ConversationVisualizerBase | None ) = DefaultConversationVisualizer, secrets: Mapping[str, SecretValue] | None = None, + stop_agent_on_close: bool = False, **_: object, ) -> None: """Remote conversation proxy that talks to an agent server. @@ -623,6 +625,7 @@ def __init__( ) self._hook_processor = HookEventProcessor(hook_manager=hook_manager) self._hook_processor.run_session_start() + self.stop_agent_on_close = stop_agent_on_close def _create_llm_completion_log_callback(self) -> ConversationCallbackType: """Create a callback that writes LLM completion logs to client filesystem.""" @@ -984,14 +987,6 @@ def close(self) -> None: if self._hook_processor is not None: self._hook_processor.run_session_end() try: - # trigger server-side delete_conversation to release resources - # like tmux sessions - _send_request( - self._client, - "DELETE", - f"/api/conversations/{self.id}", - acceptable_status_codes={200, 204}, - ) # Stop WebSocket client if it exists if self._ws_client: self._ws_client.stop() @@ -1000,6 +995,13 @@ def close(self) -> None: pass self._end_observability_span() + if self.stop_agent_on_close: + try: + # trigger server-side delete_conversation to release resources + # like tmux sessions + _send_request(self._client, "DELETE", f"/api/conversations/{self.id}") + except Exception: + pass def __del__(self) -> None: try: