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.
- openhands/sdk/agent/agent.py +90 -16
- openhands/sdk/agent/base.py +33 -46
- openhands/sdk/context/condenser/base.py +36 -3
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
- openhands/sdk/context/skills/skill.py +2 -25
- openhands/sdk/context/view.py +108 -122
- openhands/sdk/conversation/__init__.py +2 -0
- openhands/sdk/conversation/conversation.py +18 -3
- openhands/sdk/conversation/exceptions.py +18 -0
- openhands/sdk/conversation/impl/local_conversation.py +211 -36
- openhands/sdk/conversation/impl/remote_conversation.py +151 -12
- openhands/sdk/conversation/stuck_detector.py +18 -9
- openhands/sdk/critic/impl/api/critic.py +10 -7
- openhands/sdk/event/condenser.py +52 -2
- openhands/sdk/git/cached_repo.py +19 -0
- openhands/sdk/hooks/__init__.py +2 -0
- openhands/sdk/hooks/config.py +44 -4
- openhands/sdk/hooks/executor.py +2 -1
- openhands/sdk/llm/__init__.py +16 -0
- openhands/sdk/llm/auth/__init__.py +28 -0
- openhands/sdk/llm/auth/credentials.py +157 -0
- openhands/sdk/llm/auth/openai.py +762 -0
- openhands/sdk/llm/llm.py +222 -33
- openhands/sdk/llm/message.py +65 -27
- openhands/sdk/llm/options/chat_options.py +2 -1
- openhands/sdk/llm/options/responses_options.py +8 -7
- openhands/sdk/llm/utils/model_features.py +2 -0
- openhands/sdk/mcp/client.py +53 -6
- openhands/sdk/mcp/tool.py +24 -21
- openhands/sdk/mcp/utils.py +31 -23
- openhands/sdk/plugin/__init__.py +12 -1
- openhands/sdk/plugin/fetch.py +118 -14
- openhands/sdk/plugin/loader.py +111 -0
- openhands/sdk/plugin/plugin.py +155 -13
- openhands/sdk/plugin/types.py +163 -1
- openhands/sdk/secret/secrets.py +13 -1
- openhands/sdk/utils/__init__.py +2 -0
- openhands/sdk/utils/async_utils.py +36 -1
- openhands/sdk/utils/command.py +28 -1
- openhands/sdk/workspace/remote/base.py +8 -3
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
openhands/sdk/plugin/plugin.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
250
|
+
If repo_path is specified, returns the path to that subdirectory.
|
|
117
251
|
|
|
118
252
|
Raises:
|
|
119
|
-
PluginFetchError: If fetching fails or
|
|
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",
|
|
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,
|
|
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
|
-
|
|
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
|
openhands/sdk/plugin/types.py
CHANGED
|
@@ -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.
|
openhands/sdk/secret/secrets.py
CHANGED
|
@@ -92,7 +92,19 @@ class LookupSecret(SecretSource):
|
|
|
92
92
|
return result
|
|
93
93
|
|
|
94
94
|
|
|
95
|
-
|
|
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):
|
openhands/sdk/utils/__init__.py
CHANGED
|
@@ -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(
|
|
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
|
openhands/sdk/utils/command.py
CHANGED
|
@@ -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:
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|