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.
Files changed (45) hide show
  1. openhands/sdk/agent/agent.py +64 -0
  2. openhands/sdk/agent/base.py +29 -10
  3. openhands/sdk/agent/prompts/system_prompt.j2 +1 -0
  4. openhands/sdk/context/condenser/llm_summarizing_condenser.py +7 -5
  5. openhands/sdk/context/skills/skill.py +59 -1
  6. openhands/sdk/context/skills/utils.py +6 -65
  7. openhands/sdk/context/view.py +6 -11
  8. openhands/sdk/conversation/base.py +5 -0
  9. openhands/sdk/conversation/event_store.py +84 -12
  10. openhands/sdk/conversation/impl/local_conversation.py +7 -0
  11. openhands/sdk/conversation/impl/remote_conversation.py +16 -3
  12. openhands/sdk/conversation/state.py +25 -2
  13. openhands/sdk/conversation/visualizer/base.py +23 -0
  14. openhands/sdk/critic/__init__.py +4 -1
  15. openhands/sdk/critic/base.py +17 -20
  16. openhands/sdk/critic/impl/__init__.py +2 -0
  17. openhands/sdk/critic/impl/agent_finished.py +9 -5
  18. openhands/sdk/critic/impl/api/__init__.py +18 -0
  19. openhands/sdk/critic/impl/api/chat_template.py +232 -0
  20. openhands/sdk/critic/impl/api/client.py +313 -0
  21. openhands/sdk/critic/impl/api/critic.py +90 -0
  22. openhands/sdk/critic/impl/api/taxonomy.py +180 -0
  23. openhands/sdk/critic/result.py +148 -0
  24. openhands/sdk/event/conversation_error.py +12 -0
  25. openhands/sdk/event/llm_convertible/action.py +10 -0
  26. openhands/sdk/event/llm_convertible/message.py +10 -0
  27. openhands/sdk/git/cached_repo.py +459 -0
  28. openhands/sdk/git/utils.py +118 -3
  29. openhands/sdk/hooks/__init__.py +7 -1
  30. openhands/sdk/hooks/config.py +154 -45
  31. openhands/sdk/io/base.py +52 -0
  32. openhands/sdk/io/local.py +25 -0
  33. openhands/sdk/io/memory.py +34 -1
  34. openhands/sdk/llm/llm.py +6 -2
  35. openhands/sdk/llm/utils/model_features.py +3 -0
  36. openhands/sdk/llm/utils/telemetry.py +41 -2
  37. openhands/sdk/plugin/__init__.py +17 -0
  38. openhands/sdk/plugin/fetch.py +231 -0
  39. openhands/sdk/plugin/plugin.py +61 -4
  40. openhands/sdk/plugin/types.py +394 -1
  41. openhands/sdk/secret/secrets.py +19 -4
  42. {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/METADATA +6 -1
  43. {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/RECORD +45 -37
  44. {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/WHEEL +1 -1
  45. {openhands_sdk-1.8.1.dist-info → openhands_sdk-1.9.0.dist-info}/top_level.txt +0 -0
@@ -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
+ )
@@ -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
- # load() returns empty config on error, check if it has hooks
243
- if hook_config.hooks:
244
- return hook_config
245
- return None
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