openhands-sdk 1.8.1__py3-none-any.whl → 1.9.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 +64 -0
- openhands/sdk/agent/base.py +29 -10
- openhands/sdk/agent/prompts/system_prompt.j2 +1 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +7 -5
- openhands/sdk/context/skills/skill.py +59 -1
- openhands/sdk/context/skills/utils.py +6 -65
- openhands/sdk/context/view.py +6 -11
- openhands/sdk/conversation/base.py +5 -0
- openhands/sdk/conversation/event_store.py +84 -12
- openhands/sdk/conversation/impl/local_conversation.py +7 -0
- openhands/sdk/conversation/impl/remote_conversation.py +16 -3
- openhands/sdk/conversation/state.py +25 -2
- openhands/sdk/conversation/visualizer/base.py +23 -0
- openhands/sdk/critic/__init__.py +4 -1
- openhands/sdk/critic/base.py +17 -20
- openhands/sdk/critic/impl/__init__.py +2 -0
- openhands/sdk/critic/impl/agent_finished.py +9 -5
- openhands/sdk/critic/impl/api/__init__.py +18 -0
- openhands/sdk/critic/impl/api/chat_template.py +232 -0
- openhands/sdk/critic/impl/api/client.py +313 -0
- openhands/sdk/critic/impl/api/critic.py +90 -0
- openhands/sdk/critic/impl/api/taxonomy.py +180 -0
- openhands/sdk/critic/result.py +148 -0
- openhands/sdk/event/conversation_error.py +12 -0
- openhands/sdk/event/llm_convertible/action.py +10 -0
- openhands/sdk/event/llm_convertible/message.py +10 -0
- openhands/sdk/git/cached_repo.py +459 -0
- openhands/sdk/git/utils.py +118 -3
- openhands/sdk/hooks/__init__.py +7 -1
- openhands/sdk/hooks/config.py +154 -45
- openhands/sdk/io/base.py +52 -0
- openhands/sdk/io/local.py +25 -0
- openhands/sdk/io/memory.py +34 -1
- openhands/sdk/llm/llm.py +6 -2
- openhands/sdk/llm/utils/model_features.py +3 -0
- openhands/sdk/llm/utils/telemetry.py +41 -2
- openhands/sdk/plugin/__init__.py +17 -0
- openhands/sdk/plugin/fetch.py +231 -0
- openhands/sdk/plugin/plugin.py +61 -4
- openhands/sdk/plugin/types.py +394 -1
- openhands/sdk/secret/secrets.py +19 -4
- {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/METADATA +6 -1
- {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/RECORD +45 -37
- {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/top_level.txt +0 -0
openhands/sdk/plugin/__init__.py
CHANGED
|
@@ -2,21 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
This module provides support for loading and managing plugins that bundle
|
|
4
4
|
skills, hooks, MCP configurations, agents, and commands together.
|
|
5
|
+
|
|
6
|
+
It also provides support for plugin marketplaces - directories that list
|
|
7
|
+
available plugins with their metadata and source locations.
|
|
5
8
|
"""
|
|
6
9
|
|
|
10
|
+
from openhands.sdk.plugin.fetch import PluginFetchError
|
|
7
11
|
from openhands.sdk.plugin.plugin import Plugin
|
|
8
12
|
from openhands.sdk.plugin.types import (
|
|
9
13
|
AgentDefinition,
|
|
10
14
|
CommandDefinition,
|
|
15
|
+
Marketplace,
|
|
16
|
+
MarketplaceMetadata,
|
|
17
|
+
MarketplaceOwner,
|
|
18
|
+
MarketplacePluginEntry,
|
|
19
|
+
MarketplacePluginSource,
|
|
11
20
|
PluginAuthor,
|
|
12
21
|
PluginManifest,
|
|
13
22
|
)
|
|
14
23
|
|
|
15
24
|
|
|
16
25
|
__all__ = [
|
|
26
|
+
# Plugin classes
|
|
17
27
|
"Plugin",
|
|
28
|
+
"PluginFetchError",
|
|
18
29
|
"PluginManifest",
|
|
19
30
|
"PluginAuthor",
|
|
20
31
|
"AgentDefinition",
|
|
21
32
|
"CommandDefinition",
|
|
33
|
+
# Marketplace classes
|
|
34
|
+
"Marketplace",
|
|
35
|
+
"MarketplaceOwner",
|
|
36
|
+
"MarketplacePluginEntry",
|
|
37
|
+
"MarketplacePluginSource",
|
|
38
|
+
"MarketplaceMetadata",
|
|
22
39
|
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Plugin fetching utilities for remote plugin sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.git.cached_repo import GitHelper, try_cached_clone_or_update
|
|
9
|
+
from openhands.sdk.git.utils import extract_repo_name, is_git_url, normalize_git_url
|
|
10
|
+
from openhands.sdk.logger import get_logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
DEFAULT_CACHE_DIR = Path.home() / ".openhands" / "cache" / "plugins"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PluginFetchError(Exception):
|
|
19
|
+
"""Raised when fetching a plugin fails."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_plugin_source(source: str) -> tuple[str, str]:
|
|
25
|
+
"""Parse plugin source into (type, url).
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
source: Plugin source string. Can be:
|
|
29
|
+
- "github:owner/repo" - GitHub repository shorthand
|
|
30
|
+
- "https://github.com/owner/repo.git" - Full git URL
|
|
31
|
+
- "git@github.com:owner/repo.git" - SSH git URL
|
|
32
|
+
- "/local/path" - Local path
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Tuple of (source_type, normalized_url) where source_type is one of:
|
|
36
|
+
- "github": GitHub repository
|
|
37
|
+
- "git": Any git URL
|
|
38
|
+
- "local": Local filesystem path
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
>>> parse_plugin_source("github:owner/repo")
|
|
42
|
+
("github", "https://github.com/owner/repo.git")
|
|
43
|
+
>>> parse_plugin_source("https://gitlab.com/org/repo.git")
|
|
44
|
+
("git", "https://gitlab.com/org/repo.git")
|
|
45
|
+
>>> parse_plugin_source("/local/path")
|
|
46
|
+
("local", "/local/path")
|
|
47
|
+
"""
|
|
48
|
+
source = source.strip()
|
|
49
|
+
|
|
50
|
+
# GitHub shorthand: github:owner/repo
|
|
51
|
+
if source.startswith("github:"):
|
|
52
|
+
repo_path = source[7:] # Remove "github:" prefix
|
|
53
|
+
# Validate format
|
|
54
|
+
if "/" not in repo_path or repo_path.count("/") > 1:
|
|
55
|
+
raise PluginFetchError(
|
|
56
|
+
f"Invalid GitHub shorthand format: {source}. "
|
|
57
|
+
f"Expected format: github:owner/repo"
|
|
58
|
+
)
|
|
59
|
+
url = f"https://github.com/{repo_path}.git"
|
|
60
|
+
return ("github", url)
|
|
61
|
+
|
|
62
|
+
# Git URLs: detect by protocol/scheme rather than enumerating providers
|
|
63
|
+
# This handles GitHub, GitLab, Bitbucket, Codeberg, self-hosted instances, etc.
|
|
64
|
+
if is_git_url(source):
|
|
65
|
+
url = normalize_git_url(source)
|
|
66
|
+
return ("git", url)
|
|
67
|
+
|
|
68
|
+
# Local path: starts with /, ~, . or contains / without a URL scheme
|
|
69
|
+
if source.startswith(("/", "~", ".")):
|
|
70
|
+
return ("local", source)
|
|
71
|
+
|
|
72
|
+
if "/" in source and "://" not in source:
|
|
73
|
+
# Relative path like "plugins/my-plugin"
|
|
74
|
+
return ("local", source)
|
|
75
|
+
|
|
76
|
+
raise PluginFetchError(
|
|
77
|
+
f"Unable to parse plugin source: {source}. "
|
|
78
|
+
f"Expected formats: 'github:owner/repo', git URL, or local path"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_cache_path(source: str, cache_dir: Path | None = None) -> Path:
|
|
83
|
+
"""Get the cache path for a plugin source.
|
|
84
|
+
|
|
85
|
+
Creates a deterministic path based on a hash of the source URL.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
source: The plugin source (URL or path).
|
|
89
|
+
cache_dir: Base cache directory. Defaults to ~/.openhands/cache/plugins/
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Path where the plugin should be cached.
|
|
93
|
+
"""
|
|
94
|
+
if cache_dir is None:
|
|
95
|
+
cache_dir = DEFAULT_CACHE_DIR
|
|
96
|
+
|
|
97
|
+
# Create a hash of the source for the directory name
|
|
98
|
+
source_hash = hashlib.sha256(source.encode()).hexdigest()[:16]
|
|
99
|
+
|
|
100
|
+
# Extract repo name for human-readable cache directory name
|
|
101
|
+
readable_name = extract_repo_name(source)
|
|
102
|
+
|
|
103
|
+
cache_name = f"{readable_name}-{source_hash}"
|
|
104
|
+
return cache_dir / cache_name
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_local_source(url: str, subpath: str | None) -> Path:
|
|
108
|
+
"""Resolve a local plugin source to a path.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
url: Local path string (may contain ~ for home directory).
|
|
112
|
+
subpath: Optional subdirectory within the local path.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Resolved absolute path to the plugin directory.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
PluginFetchError: If path doesn't exist or subpath is invalid.
|
|
119
|
+
"""
|
|
120
|
+
local_path = Path(url).expanduser().resolve()
|
|
121
|
+
if not local_path.exists():
|
|
122
|
+
raise PluginFetchError(f"Local plugin path does not exist: {local_path}")
|
|
123
|
+
return _apply_subpath(local_path, subpath, "local plugin path")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _fetch_remote_source(
|
|
127
|
+
url: str,
|
|
128
|
+
cache_dir: Path,
|
|
129
|
+
ref: str | None,
|
|
130
|
+
update: bool,
|
|
131
|
+
subpath: str | None,
|
|
132
|
+
git_helper: GitHelper | None,
|
|
133
|
+
source: str,
|
|
134
|
+
) -> Path:
|
|
135
|
+
"""Fetch a remote plugin source and cache it locally.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
url: Git URL to fetch.
|
|
139
|
+
cache_dir: Base directory for caching.
|
|
140
|
+
ref: Optional branch, tag, or commit to checkout.
|
|
141
|
+
update: Whether to update existing cache.
|
|
142
|
+
subpath: Optional subdirectory within the repository.
|
|
143
|
+
git_helper: GitHelper instance for git operations.
|
|
144
|
+
source: Original source string (for error messages).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Path to the cached plugin directory.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
PluginFetchError: If fetching fails or subpath is invalid.
|
|
151
|
+
"""
|
|
152
|
+
plugin_path = get_cache_path(url, cache_dir)
|
|
153
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
|
|
155
|
+
result = try_cached_clone_or_update(
|
|
156
|
+
url=url,
|
|
157
|
+
repo_path=plugin_path,
|
|
158
|
+
ref=ref,
|
|
159
|
+
update=update,
|
|
160
|
+
git_helper=git_helper,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if result is None:
|
|
164
|
+
raise PluginFetchError(f"Failed to fetch plugin from {source}")
|
|
165
|
+
|
|
166
|
+
return _apply_subpath(plugin_path, subpath, "plugin repository")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _apply_subpath(base_path: Path, subpath: str | None, context: str) -> Path:
|
|
170
|
+
"""Apply a subpath to a base path, validating it exists.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
base_path: The root path.
|
|
174
|
+
subpath: Optional subdirectory path (may have leading/trailing slashes).
|
|
175
|
+
context: Description for error messages (e.g., "plugin repository").
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The final path (base_path if no subpath, otherwise base_path/subpath).
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
PluginFetchError: If subpath doesn't exist.
|
|
182
|
+
"""
|
|
183
|
+
if not subpath:
|
|
184
|
+
return base_path
|
|
185
|
+
|
|
186
|
+
final_path = base_path / subpath.strip("/")
|
|
187
|
+
if not final_path.exists():
|
|
188
|
+
raise PluginFetchError(f"Subdirectory '{subpath}' not found in {context}")
|
|
189
|
+
return final_path
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fetch_plugin(
|
|
193
|
+
source: str,
|
|
194
|
+
cache_dir: Path | None = None,
|
|
195
|
+
ref: str | None = None,
|
|
196
|
+
update: bool = True,
|
|
197
|
+
subpath: str | None = None,
|
|
198
|
+
git_helper: GitHelper | None = None,
|
|
199
|
+
) -> Path:
|
|
200
|
+
"""Fetch a plugin from a remote source and return the local cached path.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
source: Plugin source - can be:
|
|
204
|
+
- "github:owner/repo" - GitHub repository shorthand
|
|
205
|
+
- "https://github.com/owner/repo.git" - Full git URL
|
|
206
|
+
- "/local/path" - Local path (returned as-is)
|
|
207
|
+
cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
|
|
208
|
+
ref: Optional branch, tag, or commit to checkout.
|
|
209
|
+
update: If True and cache exists, update it. If False, use cached version as-is.
|
|
210
|
+
subpath: Optional subdirectory path within the repo. If specified, the returned
|
|
211
|
+
path will point to this subdirectory instead of the repository root.
|
|
212
|
+
git_helper: GitHelper instance (for testing). Defaults to global instance.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Path to the local plugin directory (ready for Plugin.load()).
|
|
216
|
+
If subpath is specified, returns the path to that subdirectory.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
PluginFetchError: If fetching fails or subpath doesn't exist.
|
|
220
|
+
"""
|
|
221
|
+
source_type, url = parse_plugin_source(source)
|
|
222
|
+
|
|
223
|
+
if source_type == "local":
|
|
224
|
+
return _resolve_local_source(url, subpath)
|
|
225
|
+
|
|
226
|
+
if cache_dir is None:
|
|
227
|
+
cache_dir = DEFAULT_CACHE_DIR
|
|
228
|
+
|
|
229
|
+
return _fetch_remote_source(
|
|
230
|
+
url, cache_dir, ref, update, subpath, git_helper, source
|
|
231
|
+
)
|
openhands/sdk/plugin/plugin.py
CHANGED
|
@@ -16,6 +16,7 @@ from openhands.sdk.context.skills.utils import (
|
|
|
16
16
|
)
|
|
17
17
|
from openhands.sdk.hooks import HookConfig
|
|
18
18
|
from openhands.sdk.logger import get_logger
|
|
19
|
+
from openhands.sdk.plugin.fetch import fetch_plugin
|
|
19
20
|
from openhands.sdk.plugin.types import (
|
|
20
21
|
AgentDefinition,
|
|
21
22
|
CommandDefinition,
|
|
@@ -83,6 +84,59 @@ class Plugin(BaseModel):
|
|
|
83
84
|
"""Get the plugin description."""
|
|
84
85
|
return self.manifest.description
|
|
85
86
|
|
|
87
|
+
@classmethod
|
|
88
|
+
def fetch(
|
|
89
|
+
cls,
|
|
90
|
+
source: str,
|
|
91
|
+
cache_dir: Path | None = None,
|
|
92
|
+
ref: str | None = None,
|
|
93
|
+
update: bool = True,
|
|
94
|
+
subpath: str | None = None,
|
|
95
|
+
) -> Path:
|
|
96
|
+
"""Fetch a plugin from a remote source and return the local cached path.
|
|
97
|
+
|
|
98
|
+
This method fetches plugins from remote sources (GitHub repositories, git URLs)
|
|
99
|
+
and caches them locally. Use the returned path with Plugin.load() to load
|
|
100
|
+
the plugin.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
source: Plugin source - can be:
|
|
104
|
+
- "github:owner/repo" - GitHub repository shorthand
|
|
105
|
+
- "https://github.com/owner/repo.git" - Full git URL
|
|
106
|
+
- "/local/path" - Local path (returned as-is)
|
|
107
|
+
cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
|
|
108
|
+
ref: Optional branch, tag, or commit to checkout.
|
|
109
|
+
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.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Path to the local plugin directory (ready for Plugin.load()).
|
|
116
|
+
If subpath is specified, returns the path to that subdirectory.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
PluginFetchError: If fetching fails or subpath doesn't exist.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> path = Plugin.fetch("github:owner/my-plugin")
|
|
123
|
+
>>> plugin = Plugin.load(path)
|
|
124
|
+
|
|
125
|
+
>>> # With specific version
|
|
126
|
+
>>> path = Plugin.fetch("github:owner/my-plugin", ref="v1.0.0")
|
|
127
|
+
>>> plugin = Plugin.load(path)
|
|
128
|
+
|
|
129
|
+
>>> # Fetch a plugin from a subdirectory
|
|
130
|
+
>>> path = Plugin.fetch("github:owner/monorepo", subpath="plugins/sub")
|
|
131
|
+
>>> plugin = Plugin.load(path)
|
|
132
|
+
|
|
133
|
+
>>> # Fetch and load in one step
|
|
134
|
+
>>> plugin = Plugin.load(Plugin.fetch("github:owner/my-plugin"))
|
|
135
|
+
"""
|
|
136
|
+
return fetch_plugin(
|
|
137
|
+
source, cache_dir=cache_dir, ref=ref, update=update, subpath=subpath
|
|
138
|
+
)
|
|
139
|
+
|
|
86
140
|
@classmethod
|
|
87
141
|
def load(cls, plugin_path: str | Path) -> Plugin:
|
|
88
142
|
"""Load a plugin from a directory.
|
|
@@ -239,10 +293,13 @@ def _load_hooks(plugin_dir: Path) -> HookConfig | None:
|
|
|
239
293
|
|
|
240
294
|
try:
|
|
241
295
|
hook_config = HookConfig.load(path=hooks_json)
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
296
|
+
# If hooks.json exists but is invalid, HookConfig.load() returns an empty
|
|
297
|
+
# config and logs the validation error. Keep that distinct from "file not
|
|
298
|
+
# present" (None).
|
|
299
|
+
if hook_config.is_empty():
|
|
300
|
+
logger.info(f"No hooks configured in {hooks_json}")
|
|
301
|
+
return HookConfig()
|
|
302
|
+
return hook_config
|
|
246
303
|
except Exception as e:
|
|
247
304
|
logger.warning(f"Failed to load hooks from {hooks_json}: {e}")
|
|
248
305
|
return None
|