diff --git a/openhands-sdk/openhands/sdk/agent/base.py b/openhands-sdk/openhands/sdk/agent/base.py index a364dbadea..4e30cb3d27 100644 --- a/openhands-sdk/openhands/sdk/agent/base.py +++ b/openhands-sdk/openhands/sdk/agent/base.py @@ -497,5 +497,5 @@ def tools_map(self) -> dict[str, ToolDefinition]: RuntimeError: If the agent has not been initialized. """ if not self._initialized: - 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/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 e2243719aa..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.""" @@ -992,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: diff --git a/openhands-sdk/openhands/sdk/workspace/remote/base.py b/openhands-sdk/openhands/sdk/workspace/remote/base.py index 0024fed3c7..1179392b57 100644 --- a/openhands-sdk/openhands/sdk/workspace/remote/base.py +++ b/openhands-sdk/openhands/sdk/workspace/remote/base.py @@ -50,12 +50,17 @@ 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=60.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, headers=self._headers + base_url=self.host, + timeout=timeout, + headers=self._headers, + limits=httpx.Limits(max_connections=None), ) self._client = client return client 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 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,