openhands-sdk 1.9.1__py3-none-any.whl → 1.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. openhands/sdk/agent/agent.py +90 -16
  2. openhands/sdk/agent/base.py +33 -46
  3. openhands/sdk/context/condenser/base.py +36 -3
  4. openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
  5. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
  6. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
  7. openhands/sdk/context/skills/skill.py +2 -25
  8. openhands/sdk/context/view.py +108 -122
  9. openhands/sdk/conversation/__init__.py +2 -0
  10. openhands/sdk/conversation/conversation.py +18 -3
  11. openhands/sdk/conversation/exceptions.py +18 -0
  12. openhands/sdk/conversation/impl/local_conversation.py +211 -36
  13. openhands/sdk/conversation/impl/remote_conversation.py +151 -12
  14. openhands/sdk/conversation/stuck_detector.py +18 -9
  15. openhands/sdk/critic/impl/api/critic.py +10 -7
  16. openhands/sdk/event/condenser.py +52 -2
  17. openhands/sdk/git/cached_repo.py +19 -0
  18. openhands/sdk/hooks/__init__.py +2 -0
  19. openhands/sdk/hooks/config.py +44 -4
  20. openhands/sdk/hooks/executor.py +2 -1
  21. openhands/sdk/llm/__init__.py +16 -0
  22. openhands/sdk/llm/auth/__init__.py +28 -0
  23. openhands/sdk/llm/auth/credentials.py +157 -0
  24. openhands/sdk/llm/auth/openai.py +762 -0
  25. openhands/sdk/llm/llm.py +222 -33
  26. openhands/sdk/llm/message.py +65 -27
  27. openhands/sdk/llm/options/chat_options.py +2 -1
  28. openhands/sdk/llm/options/responses_options.py +8 -7
  29. openhands/sdk/llm/utils/model_features.py +2 -0
  30. openhands/sdk/mcp/client.py +53 -6
  31. openhands/sdk/mcp/tool.py +24 -21
  32. openhands/sdk/mcp/utils.py +31 -23
  33. openhands/sdk/plugin/__init__.py +12 -1
  34. openhands/sdk/plugin/fetch.py +118 -14
  35. openhands/sdk/plugin/loader.py +111 -0
  36. openhands/sdk/plugin/plugin.py +155 -13
  37. openhands/sdk/plugin/types.py +163 -1
  38. openhands/sdk/secret/secrets.py +13 -1
  39. openhands/sdk/utils/__init__.py +2 -0
  40. openhands/sdk/utils/async_utils.py +36 -1
  41. openhands/sdk/utils/command.py +28 -1
  42. openhands/sdk/workspace/remote/base.py +8 -3
  43. openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
  44. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
  45. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
  46. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
  47. {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from pathlib import Path
7
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from pydantic import BaseModel, Field
10
10
 
@@ -25,6 +25,9 @@ from openhands.sdk.plugin.types import (
25
25
  )
26
26
 
27
27
 
28
+ if TYPE_CHECKING:
29
+ from openhands.sdk.context import AgentContext
30
+
28
31
  logger = get_logger(__name__)
29
32
 
30
33
  # Directories to check for plugin manifest
@@ -84,6 +87,135 @@ class Plugin(BaseModel):
84
87
  """Get the plugin description."""
85
88
  return self.manifest.description
86
89
 
90
+ def get_all_skills(self) -> list[Skill]:
91
+ """Get all skills including those converted from commands.
92
+
93
+ Returns skills from both the skills/ directory and commands/ directory.
94
+ Commands are converted to keyword-triggered skills using the format
95
+ /<plugin-name>:<command-name>.
96
+
97
+ Returns:
98
+ Combined list of skills (original + command-derived skills).
99
+ """
100
+ all_skills = list(self.skills)
101
+
102
+ # Convert commands to skills with keyword triggers
103
+ for command in self.commands:
104
+ skill = command.to_skill(self.name)
105
+ all_skills.append(skill)
106
+
107
+ return all_skills
108
+
109
+ def add_skills_to(
110
+ self,
111
+ agent_context: AgentContext | None = None,
112
+ max_skills: int | None = None,
113
+ ) -> AgentContext:
114
+ """Add this plugin's skills to an agent context.
115
+
116
+ Plugin skills override existing skills with the same name.
117
+ Includes both explicit skills and command-derived skills.
118
+
119
+ Args:
120
+ agent_context: Existing agent context (or None to create new)
121
+ max_skills: Optional max total skills (raises ValueError if exceeded)
122
+
123
+ Returns:
124
+ New AgentContext with this plugin's skills added
125
+
126
+ Raises:
127
+ ValueError: If max_skills limit would be exceeded
128
+
129
+ Example:
130
+ >>> plugin = Plugin.load(Plugin.fetch("github:owner/plugin"))
131
+ >>> new_context = plugin.add_skills_to(agent.agent_context, max_skills=100)
132
+ >>> agent = agent.model_copy(update={"agent_context": new_context})
133
+ """
134
+ # Import at runtime to avoid circular import
135
+ from openhands.sdk.context import AgentContext
136
+
137
+ existing_skills = agent_context.skills if agent_context else []
138
+
139
+ # Get all skills including command-derived skills
140
+ all_skills = self.get_all_skills()
141
+
142
+ skills_by_name = {s.name: s for s in existing_skills}
143
+ for skill in all_skills:
144
+ if skill.name in skills_by_name:
145
+ logger.warning(f"Plugin skill '{skill.name}' overrides existing skill")
146
+ skills_by_name[skill.name] = skill
147
+
148
+ if max_skills is not None and len(skills_by_name) > max_skills:
149
+ raise ValueError(
150
+ f"Total skills ({len(skills_by_name)}) exceeds maximum ({max_skills})"
151
+ )
152
+
153
+ merged_skills = list(skills_by_name.values())
154
+
155
+ if agent_context:
156
+ return agent_context.model_copy(update={"skills": merged_skills})
157
+ return AgentContext(skills=merged_skills)
158
+
159
+ def add_mcp_config_to(
160
+ self,
161
+ mcp_config: dict[str, Any] | None = None,
162
+ ) -> dict[str, Any]:
163
+ """Add this plugin's MCP servers to an MCP config.
164
+
165
+ Plugin MCP servers override existing servers with the same name.
166
+
167
+ Merge semantics (Claude Code compatible):
168
+ - mcpServers: deep-merge by server name (last plugin wins for same server)
169
+ - Other top-level keys: shallow override (plugin wins)
170
+
171
+ Args:
172
+ mcp_config: Existing MCP config (or None to create new)
173
+
174
+ Returns:
175
+ New MCP config dict with this plugin's servers added
176
+
177
+ Example:
178
+ >>> plugin = Plugin.load(Plugin.fetch("github:owner/plugin"))
179
+ >>> new_mcp = plugin.add_mcp_config_to(agent.mcp_config)
180
+ >>> agent = agent.model_copy(update={"mcp_config": new_mcp})
181
+ """
182
+ base_config = mcp_config
183
+ plugin_config = self.mcp_config
184
+
185
+ if base_config is None and plugin_config is None:
186
+ return {}
187
+ if base_config is None:
188
+ return dict(plugin_config) if plugin_config else {}
189
+ if plugin_config is None:
190
+ return dict(base_config)
191
+
192
+ # Shallow copy to avoid mutating inputs
193
+ result = dict(base_config)
194
+
195
+ # Merge mcpServers by server name (Claude Code compatible behavior)
196
+ if "mcpServers" in plugin_config:
197
+ existing_servers = result.get("mcpServers", {})
198
+ for server_name in plugin_config["mcpServers"]:
199
+ if server_name in existing_servers:
200
+ logger.warning(
201
+ f"Plugin MCP server '{server_name}' overrides existing server"
202
+ )
203
+ result["mcpServers"] = {
204
+ **existing_servers,
205
+ **plugin_config["mcpServers"],
206
+ }
207
+
208
+ # Other top-level keys: plugin wins (shallow override)
209
+ for key, value in plugin_config.items():
210
+ if key != "mcpServers":
211
+ if key in result:
212
+ logger.warning(
213
+ f"Plugin MCP config key '{key}' overrides existing value"
214
+ )
215
+ result[key] = value
216
+
217
+ return result
218
+
87
219
  @classmethod
88
220
  def fetch(
89
221
  cls,
@@ -91,7 +223,7 @@ class Plugin(BaseModel):
91
223
  cache_dir: Path | None = None,
92
224
  ref: str | None = None,
93
225
  update: bool = True,
94
- subpath: str | None = None,
226
+ repo_path: str | None = None,
95
227
  ) -> Path:
96
228
  """Fetch a plugin from a remote source and return the local cached path.
97
229
 
@@ -101,22 +233,24 @@ class Plugin(BaseModel):
101
233
 
102
234
  Args:
103
235
  source: Plugin source - can be:
104
- - "github:owner/repo" - GitHub repository shorthand
105
- - "https://github.com/owner/repo.git" - Full git URL
236
+ - Any git URL (GitHub, GitLab, Bitbucket, Codeberg, self-hosted, etc.)
237
+ e.g., "https://gitlab.com/org/repo", "git@bitbucket.org:team/repo.git"
238
+ - "github:owner/repo" - GitHub shorthand (convenience syntax)
106
239
  - "/local/path" - Local path (returned as-is)
107
240
  cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
108
241
  ref: Optional branch, tag, or commit to checkout.
109
242
  update: If True and cache exists, update it. If False, use cached as-is.
110
- subpath: Optional subdirectory path within the repo. If specified, the
111
- returned path will point to this subdirectory instead of the
112
- repository root.
243
+ repo_path: Subdirectory path within the git repository
244
+ (e.g., 'plugins/my-plugin' for monorepos). Only relevant for git
245
+ sources, not local paths. If specified, the returned path will
246
+ point to this subdirectory instead of the repository root.
113
247
 
114
248
  Returns:
115
249
  Path to the local plugin directory (ready for Plugin.load()).
116
- If subpath is specified, returns the path to that subdirectory.
250
+ If repo_path is specified, returns the path to that subdirectory.
117
251
 
118
252
  Raises:
119
- PluginFetchError: If fetching fails or subpath doesn't exist.
253
+ PluginFetchError: If fetching fails or repo_path doesn't exist.
120
254
 
121
255
  Example:
122
256
  >>> path = Plugin.fetch("github:owner/my-plugin")
@@ -126,15 +260,15 @@ class Plugin(BaseModel):
126
260
  >>> path = Plugin.fetch("github:owner/my-plugin", ref="v1.0.0")
127
261
  >>> plugin = Plugin.load(path)
128
262
 
129
- >>> # Fetch a plugin from a subdirectory
130
- >>> path = Plugin.fetch("github:owner/monorepo", subpath="plugins/sub")
263
+ >>> # Fetch a plugin from a subdirectory in a monorepo
264
+ >>> path = Plugin.fetch("github:owner/monorepo", repo_path="plugins/sub")
131
265
  >>> plugin = Plugin.load(path)
132
266
 
133
267
  >>> # Fetch and load in one step
134
268
  >>> plugin = Plugin.load(Plugin.fetch("github:owner/my-plugin"))
135
269
  """
136
270
  return fetch_plugin(
137
- source, cache_dir=cache_dir, ref=ref, update=update, subpath=subpath
271
+ source, cache_dir=cache_dir, ref=ref, update=update, repo_path=repo_path
138
272
  )
139
273
 
140
274
  @classmethod
@@ -299,6 +433,7 @@ def _load_hooks(plugin_dir: Path) -> HookConfig | None:
299
433
  if hook_config.is_empty():
300
434
  logger.info(f"No hooks configured in {hooks_json}")
301
435
  return HookConfig()
436
+ logger.info(f"Loaded hooks from {hooks_json}")
302
437
  return hook_config
303
438
  except Exception as e:
304
439
  logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
@@ -312,7 +447,14 @@ def _load_mcp_config(plugin_dir: Path) -> dict[str, Any] | None:
312
447
  return None
313
448
 
314
449
  try:
315
- return load_mcp_config(mcp_json, skill_root=plugin_dir)
450
+ config = load_mcp_config(mcp_json, skill_root=plugin_dir)
451
+ if config and "mcpServers" in config:
452
+ server_names = list(config["mcpServers"].keys())
453
+ logger.info(
454
+ f"Loaded MCP config from {mcp_json} "
455
+ f"with {len(server_names)} server(s): {server_names}"
456
+ )
457
+ return config
316
458
  except Exception as e:
317
459
  logger.warning(f"Failed to load MCP config from {mcp_json}: {e}")
318
460
  return None
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import re
7
7
  from pathlib import Path
8
- from typing import Any
8
+ from typing import TYPE_CHECKING, Any
9
9
 
10
10
  import frontmatter
11
11
  from pydantic import BaseModel, Field, field_validator, model_validator
@@ -15,6 +15,117 @@ from pydantic import BaseModel, Field, field_validator, model_validator
15
15
  MARKETPLACE_MANIFEST_DIRS = [".plugin", ".claude-plugin"]
16
16
  MARKETPLACE_MANIFEST_FILE = "marketplace.json"
17
17
 
18
+
19
+ class PluginSource(BaseModel):
20
+ """Specification for a plugin to load.
21
+
22
+ This model describes where to find a plugin and is used by load_plugins()
23
+ to fetch and load plugins from various sources.
24
+
25
+ Examples:
26
+ >>> # GitHub repository
27
+ >>> PluginSource(source="github:owner/repo", ref="v1.0.0")
28
+
29
+ >>> # Plugin from monorepo subdirectory
30
+ >>> PluginSource(
31
+ ... source="github:owner/monorepo",
32
+ ... repo_path="plugins/my-plugin"
33
+ ... )
34
+
35
+ >>> # Local path
36
+ >>> PluginSource(source="/path/to/plugin")
37
+ """
38
+
39
+ source: str = Field(
40
+ description="Plugin source: 'github:owner/repo', any git URL, or local path"
41
+ )
42
+ ref: str | None = Field(
43
+ default=None,
44
+ description="Optional branch, tag, or commit (only for git sources)",
45
+ )
46
+ repo_path: str | None = Field(
47
+ default=None,
48
+ description=(
49
+ "Subdirectory path within the git repository "
50
+ "(e.g., 'plugins/my-plugin' for monorepos). "
51
+ "Only relevant for git sources, not local paths."
52
+ ),
53
+ )
54
+
55
+ @field_validator("repo_path")
56
+ @classmethod
57
+ def validate_repo_path(cls, v: str | None) -> str | None:
58
+ """Validate repo_path is a safe relative path within the repository."""
59
+ if v is None:
60
+ return v
61
+ # Must be relative (no absolute paths)
62
+ if v.startswith("/"):
63
+ raise ValueError("repo_path must be relative, not absolute")
64
+ # No parent directory traversal
65
+ if ".." in Path(v).parts:
66
+ raise ValueError(
67
+ "repo_path cannot contain '..' (parent directory traversal)"
68
+ )
69
+ return v
70
+
71
+
72
+ class ResolvedPluginSource(BaseModel):
73
+ """A plugin source with resolved ref (pinned to commit SHA).
74
+
75
+ Used for persistence to ensure deterministic behavior across pause/resume.
76
+ When a conversation is resumed, the resolved ref ensures we get exactly
77
+ the same plugin version that was used when the conversation started.
78
+
79
+ The resolved_ref is the actual commit SHA that was fetched, even if the
80
+ original ref was a branch name like 'main'. This prevents drift when
81
+ branches are updated between pause and resume.
82
+ """
83
+
84
+ source: str = Field(
85
+ description="Plugin source: 'github:owner/repo', any git URL, or local path"
86
+ )
87
+ resolved_ref: str | None = Field(
88
+ default=None,
89
+ description=(
90
+ "Resolved commit SHA (for git sources). None for local paths. "
91
+ "This is the actual commit that was checked out, even if the "
92
+ "original ref was a branch name."
93
+ ),
94
+ )
95
+ repo_path: str | None = Field(
96
+ default=None,
97
+ description="Subdirectory path within the git repository",
98
+ )
99
+ original_ref: str | None = Field(
100
+ default=None,
101
+ description="Original ref from PluginSource (for debugging/display)",
102
+ )
103
+
104
+ @classmethod
105
+ def from_plugin_source(
106
+ cls, plugin_source: PluginSource, resolved_ref: str | None
107
+ ) -> ResolvedPluginSource:
108
+ """Create a ResolvedPluginSource from a PluginSource and resolved ref."""
109
+ return cls(
110
+ source=plugin_source.source,
111
+ resolved_ref=resolved_ref,
112
+ repo_path=plugin_source.repo_path,
113
+ original_ref=plugin_source.ref,
114
+ )
115
+
116
+ def to_plugin_source(self) -> PluginSource:
117
+ """Convert back to PluginSource using the resolved ref.
118
+
119
+ When loading from persistence, use the resolved_ref to ensure we get
120
+ the exact same version that was originally fetched.
121
+ """
122
+ return PluginSource(
123
+ source=self.source,
124
+ ref=self.resolved_ref, # Use resolved SHA, not original ref
125
+ repo_path=self.repo_path,
126
+ )
127
+
128
+
18
129
  # Type aliases for marketplace plugin entry configurations
19
130
  # These provide better documentation than dict[str, Any] while remaining flexible
20
131
 
@@ -35,6 +146,10 @@ type LspServersDict = dict[str, dict[str, Any]]
35
146
  type HooksConfigDict = dict[str, Any]
36
147
 
37
148
 
149
+ if TYPE_CHECKING:
150
+ from openhands.sdk.context.skills import Skill
151
+
152
+
38
153
  class PluginAuthor(BaseModel):
39
154
  """Author information for a plugin."""
40
155
 
@@ -250,6 +365,53 @@ class CommandDefinition(BaseModel):
250
365
  metadata=metadata,
251
366
  )
252
367
 
368
+ def to_skill(self, plugin_name: str) -> Skill:
369
+ """Convert this command to a keyword-triggered Skill.
370
+
371
+ Creates a Skill with a KeywordTrigger using the Claude Code namespacing
372
+ format: /<plugin-name>:<command-name>
373
+
374
+ Args:
375
+ plugin_name: The name of the plugin this command belongs to.
376
+
377
+ Returns:
378
+ A Skill object with the command content and a KeywordTrigger.
379
+
380
+ Example:
381
+ For a plugin "city-weather" with command "now":
382
+ - Trigger keyword: "/city-weather:now"
383
+ - When user types "/city-weather:now Tokyo", the skill activates
384
+ """
385
+ from openhands.sdk.context.skills import Skill
386
+ from openhands.sdk.context.skills.trigger import KeywordTrigger
387
+
388
+ # Build the trigger keyword in Claude Code namespace format
389
+ trigger_keyword = f"/{plugin_name}:{self.name}"
390
+
391
+ # Build skill content with $ARGUMENTS placeholder context
392
+ content_parts = []
393
+ if self.description:
394
+ content_parts.append(f"## {self.name}\n\n{self.description}\n")
395
+
396
+ if self.argument_hint:
397
+ content_parts.append(
398
+ f"**Arguments**: `$ARGUMENTS` - {self.argument_hint}\n"
399
+ )
400
+
401
+ if self.content:
402
+ content_parts.append(f"\n{self.content}")
403
+
404
+ skill_content = "\n".join(content_parts).strip()
405
+
406
+ return Skill(
407
+ name=f"{plugin_name}:{self.name}",
408
+ content=skill_content,
409
+ description=self.description or f"Command {self.name} from {plugin_name}",
410
+ trigger=KeywordTrigger(keywords=[trigger_keyword]),
411
+ source=self.source,
412
+ allowed_tools=self.allowed_tools if self.allowed_tools else None,
413
+ )
414
+
253
415
 
254
416
  class MarketplaceOwner(BaseModel):
255
417
  """Owner information for a marketplace.
@@ -92,7 +92,19 @@ class LookupSecret(SecretSource):
92
92
  return result
93
93
 
94
94
 
95
- _SECRET_HEADERS = ["AUTHORIZATION", "KEY", "SECRET"]
95
+ # Patterns used for substring matching against header names (case-insensitive).
96
+ # Headers containing any of these patterns will be redacted during serialization.
97
+ # Examples: X-Access-Token, Cookie, Authorization, X-API-Key, X-API-Secret
98
+ _SECRET_HEADERS = [
99
+ "AUTHORIZATION",
100
+ "COOKIE",
101
+ "CREDENTIAL",
102
+ "KEY",
103
+ "PASSWORD",
104
+ "SECRET",
105
+ "SESSION",
106
+ "TOKEN",
107
+ ]
96
108
 
97
109
 
98
110
  def _is_secret_header(key: str):
@@ -1,5 +1,6 @@
1
1
  """Utility functions for the OpenHands SDK."""
2
2
 
3
+ from .command import sanitized_env
3
4
  from .deprecation import (
4
5
  deprecated,
5
6
  warn_deprecated,
@@ -19,4 +20,5 @@ __all__ = [
19
20
  "deprecated",
20
21
  "warn_deprecated",
21
22
  "sanitize_openhands_mentions",
23
+ "sanitized_env",
22
24
  ]
@@ -5,7 +5,9 @@ of synchronous conversation handling.
5
5
  """
6
6
 
7
7
  import asyncio
8
+ import threading
8
9
  from collections.abc import Callable, Coroutine
10
+ from concurrent.futures import Future
9
11
  from typing import Any
10
12
 
11
13
  from openhands.sdk.event.base import Event
@@ -21,10 +23,14 @@ class AsyncCallbackWrapper:
21
23
  but internally executes an async callback in an event loop running in a
22
24
  different thread. This allows async callbacks to be used in synchronous
23
25
  conversation contexts.
26
+
27
+ Tracks pending futures to allow waiting for all callbacks to complete.
24
28
  """
25
29
 
26
30
  async_callback: AsyncConversationCallback
27
31
  loop: asyncio.AbstractEventLoop
32
+ _pending_futures: list[Future]
33
+ _lock: threading.Lock
28
34
 
29
35
  def __init__(
30
36
  self,
@@ -33,7 +39,36 @@ class AsyncCallbackWrapper:
33
39
  ):
34
40
  self.async_callback = async_callback
35
41
  self.loop = loop
42
+ self._pending_futures = []
43
+ self._lock = threading.Lock()
36
44
 
37
45
  def __call__(self, event: Event):
38
46
  if self.loop.is_running():
39
- asyncio.run_coroutine_threadsafe(self.async_callback(event), self.loop)
47
+ future = asyncio.run_coroutine_threadsafe(
48
+ self.async_callback(event), self.loop
49
+ )
50
+ with self._lock:
51
+ # Clean up completed futures to avoid unbounded memory growth
52
+ self._pending_futures = [
53
+ f for f in self._pending_futures if not f.done()
54
+ ]
55
+ self._pending_futures.append(future)
56
+
57
+ def wait_for_pending(self, timeout: float | None = None) -> None:
58
+ """Wait for all pending callbacks to complete.
59
+
60
+ Args:
61
+ timeout: Maximum time to wait in seconds. None means wait indefinitely.
62
+
63
+ Raises:
64
+ TimeoutError: If timeout is exceeded while waiting.
65
+ """
66
+ with self._lock:
67
+ futures = list(self._pending_futures)
68
+
69
+ for future in futures:
70
+ try:
71
+ future.result(timeout=timeout)
72
+ except Exception:
73
+ # Exceptions in callbacks are already logged, ignore here
74
+ pass
@@ -1,7 +1,9 @@
1
+ import os
1
2
  import shlex
2
3
  import subprocess
3
4
  import sys
4
5
  import threading
6
+ from collections.abc import Mapping
5
7
 
6
8
  from openhands.sdk.logger import get_logger
7
9
 
@@ -9,6 +11,31 @@ from openhands.sdk.logger import get_logger
9
11
  logger = get_logger(__name__)
10
12
 
11
13
 
14
+ def sanitized_env(
15
+ env: Mapping[str, str] | None = None,
16
+ ) -> dict[str, str]:
17
+ """Return a copy of *env* with sanitized values.
18
+
19
+ PyInstaller-based binaries rewrite ``LD_LIBRARY_PATH`` so their vendored
20
+ libraries win. This function restores the original value so that subprocess
21
+ will not use them.
22
+ """
23
+
24
+ base_env: dict[str, str]
25
+ if env is None:
26
+ base_env = dict(os.environ)
27
+ else:
28
+ base_env = dict(env)
29
+
30
+ if "LD_LIBRARY_PATH_ORIG" in base_env:
31
+ origin = base_env["LD_LIBRARY_PATH_ORIG"]
32
+ if origin:
33
+ base_env["LD_LIBRARY_PATH"] = origin
34
+ else:
35
+ base_env.pop("LD_LIBRARY_PATH", None)
36
+ return base_env
37
+
38
+
12
39
  def execute_command(
13
40
  cmd: list[str] | str,
14
41
  env: dict[str, str] | None = None,
@@ -29,7 +56,7 @@ def execute_command(
29
56
  proc = subprocess.Popen(
30
57
  cmd_to_run,
31
58
  cwd=cwd,
32
- env=env,
59
+ env=sanitized_env(env),
33
60
  stdout=subprocess.PIPE,
34
61
  stderr=subprocess.PIPE,
35
62
  text=True,
@@ -50,12 +50,17 @@ class RemoteWorkspace(RemoteWorkspaceMixin, BaseWorkspace):
50
50
  if client is None:
51
51
  # Configure reasonable timeouts for HTTP requests
52
52
  # - connect: 10 seconds to establish connection
53
- # - read: 60 seconds to read response (for LLM operations)
53
+ # - read: 600 seconds (10 minutes) to read response (for LLM operations)
54
54
  # - write: 10 seconds to send request
55
55
  # - pool: 10 seconds to get connection from pool
56
- timeout = httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=10.0)
56
+ timeout = httpx.Timeout(
57
+ connect=10.0, read=self.read_timeout, write=10.0, pool=10.0
58
+ )
57
59
  client = httpx.Client(
58
- base_url=self.host, timeout=timeout, headers=self._headers
60
+ base_url=self.host,
61
+ timeout=timeout,
62
+ headers=self._headers,
63
+ limits=httpx.Limits(max_connections=self.max_connections),
59
64
  )
60
65
  self._client = client
61
66
  return client
@@ -25,6 +25,15 @@ class RemoteWorkspaceMixin(BaseModel):
25
25
  working_dir: str = Field(
26
26
  description="The working directory for agent operations and tool execution."
27
27
  )
28
+ read_timeout: float = Field(
29
+ default=600.0,
30
+ description="Timeout in seconds for reading operations of httpx.Client.",
31
+ )
32
+ max_connections: int | None = Field(
33
+ default=None,
34
+ description="Maximum number of connections for httpx.Client. "
35
+ "None means no limit, useful for running many conversations in parallel.",
36
+ )
28
37
 
29
38
  def model_post_init(self, context: Any) -> None:
30
39
  # Set up remote host
@@ -87,26 +96,50 @@ class RemoteWorkspaceMixin(BaseModel):
87
96
  stdout_parts = []
88
97
  stderr_parts = []
89
98
  exit_code = None
99
+ last_order = -1 # Track highest order seen to fetch only new events
100
+ seen_event_ids: set[str] = set() # Track seen IDs to detect duplicates
90
101
 
91
102
  while time.time() - start_time < timeout:
92
- # Search for all events
103
+ # Search for new events (order > last_order)
104
+ params: dict[str, str | int] = {
105
+ "command_id__eq": command_id,
106
+ "sort_order": "TIMESTAMP",
107
+ "limit": 100,
108
+ "kind__eq": "BashOutput",
109
+ }
110
+ if last_order >= 0:
111
+ params["order__gt"] = last_order
112
+
93
113
  response = yield {
94
114
  "method": "GET",
95
115
  "url": f"{self.host}/api/bash/bash_events/search",
96
- "params": {
97
- "command_id__eq": command_id,
98
- "sort_order": "TIMESTAMP",
99
- "limit": 100,
100
- },
116
+ "params": params,
101
117
  "headers": self._headers,
102
118
  "timeout": timeout,
103
119
  }
104
120
  response.raise_for_status()
105
121
  search_result = response.json()
106
122
 
107
- # Filter for BashOutput events for this command
123
+ # Process BashOutput events
108
124
  for event in search_result.get("items", []):
109
125
  if event.get("kind") == "BashOutput":
126
+ # Check for duplicates - safety check in case caller
127
+ # forgets to add kind__eq filter or API has a bug
128
+ event_id = event.get("id")
129
+ if event_id is not None:
130
+ if event_id in seen_event_ids:
131
+ raise RuntimeError(
132
+ f"Duplicate event received: {event_id}. "
133
+ "This should not happen with order__gt "
134
+ "filtering and kind filtering."
135
+ )
136
+ seen_event_ids.add(event_id)
137
+
138
+ # Track the highest order we've seen
139
+ event_order = event.get("order")
140
+ if event_order is not None and event_order > last_order:
141
+ last_order = event_order
142
+
110
143
  if event.get("stdout"):
111
144
  stdout_parts.append(event["stdout"])
112
145
  if event.get("stderr"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-sdk
3
- Version: 1.9.1
3
+ Version: 1.11.0
4
4
  Summary: OpenHands SDK - Core functionality for building AI agents
5
5
  Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
6
6
  Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk