openhands-sdk 1.9.0__py3-none-any.whl → 1.10.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 +54 -13
- openhands/sdk/agent/base.py +32 -45
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +0 -23
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
- openhands/sdk/context/view.py +108 -122
- openhands/sdk/conversation/__init__.py +2 -0
- openhands/sdk/conversation/conversation.py +13 -3
- openhands/sdk/conversation/exceptions.py +18 -0
- openhands/sdk/conversation/impl/local_conversation.py +192 -23
- openhands/sdk/conversation/impl/remote_conversation.py +141 -12
- 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/llm.py +47 -13
- openhands/sdk/llm/message.py +65 -27
- openhands/sdk/llm/options/chat_options.py +2 -1
- 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/utils/__init__.py +2 -0
- openhands/sdk/utils/async_utils.py +36 -1
- openhands/sdk/utils/command.py +28 -1
- {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/RECORD +34 -33
- {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.9.0.dist-info → openhands_sdk-1.10.0.dist-info}/top_level.txt +0 -0
openhands/sdk/mcp/utils.py
CHANGED
|
@@ -10,7 +10,6 @@ from openhands.sdk.logger import get_logger
|
|
|
10
10
|
from openhands.sdk.mcp.client import MCPClient
|
|
11
11
|
from openhands.sdk.mcp.exceptions import MCPTimeoutError
|
|
12
12
|
from openhands.sdk.mcp.tool import MCPToolDefinition
|
|
13
|
-
from openhands.sdk.tool.tool import ToolDefinition
|
|
14
13
|
|
|
15
14
|
|
|
16
15
|
logger = get_logger(__name__)
|
|
@@ -32,37 +31,38 @@ async def log_handler(message: LogMessage):
|
|
|
32
31
|
logger.log(level, msg, extra=extra)
|
|
33
32
|
|
|
34
33
|
|
|
35
|
-
async def
|
|
36
|
-
"""
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
for mcp_tool in mcp_type_tools:
|
|
43
|
-
tool_sequence = MCPToolDefinition.create(
|
|
44
|
-
mcp_tool=mcp_tool, mcp_client=client
|
|
45
|
-
)
|
|
46
|
-
tools.extend(tool_sequence) # Flatten sequence into list
|
|
47
|
-
assert not client.is_connected(), (
|
|
48
|
-
"MCP client should be disconnected after listing tools."
|
|
49
|
-
)
|
|
50
|
-
return tools
|
|
34
|
+
async def _connect_and_list_tools(client: MCPClient) -> None:
|
|
35
|
+
"""Connect to MCP server and populate client._tools."""
|
|
36
|
+
await client.connect()
|
|
37
|
+
mcp_type_tools: list[mcp.types.Tool] = await client.list_tools()
|
|
38
|
+
for mcp_tool in mcp_type_tools:
|
|
39
|
+
tool_sequence = MCPToolDefinition.create(mcp_tool=mcp_tool, mcp_client=client)
|
|
40
|
+
client._tools.extend(tool_sequence)
|
|
51
41
|
|
|
52
42
|
|
|
53
43
|
def create_mcp_tools(
|
|
54
44
|
config: dict | MCPConfig,
|
|
55
45
|
timeout: float = 30.0,
|
|
56
|
-
) ->
|
|
57
|
-
"""Create MCP tools from MCP configuration.
|
|
58
|
-
|
|
46
|
+
) -> MCPClient:
|
|
47
|
+
"""Create MCP tools from MCP configuration.
|
|
48
|
+
|
|
49
|
+
Returns an MCPClient with tools populated. Use as a context manager:
|
|
50
|
+
|
|
51
|
+
with create_mcp_tools(config) as client:
|
|
52
|
+
for tool in client.tools:
|
|
53
|
+
# use tool
|
|
54
|
+
# Connection automatically closed
|
|
55
|
+
"""
|
|
59
56
|
if isinstance(config, dict):
|
|
60
57
|
config = MCPConfig.model_validate(config)
|
|
61
58
|
client = MCPClient(config, log_handler=log_handler)
|
|
62
59
|
|
|
63
60
|
try:
|
|
64
|
-
|
|
61
|
+
client.call_async_from_sync(
|
|
62
|
+
_connect_and_list_tools, timeout=timeout, client=client
|
|
63
|
+
)
|
|
65
64
|
except TimeoutError as e:
|
|
65
|
+
client.sync_close()
|
|
66
66
|
# Extract server names from config for better error message
|
|
67
67
|
server_names = (
|
|
68
68
|
list(config.mcpServers.keys()) if config.mcpServers else ["unknown"]
|
|
@@ -78,6 +78,14 @@ def create_mcp_tools(
|
|
|
78
78
|
raise MCPTimeoutError(
|
|
79
79
|
error_msg, timeout=timeout, config=config.model_dump()
|
|
80
80
|
) from e
|
|
81
|
+
except BaseException:
|
|
82
|
+
try:
|
|
83
|
+
client.sync_close()
|
|
84
|
+
except Exception as close_exc:
|
|
85
|
+
logger.warning(
|
|
86
|
+
"Failed to close MCP client during error cleanup", exc_info=close_exc
|
|
87
|
+
)
|
|
88
|
+
raise
|
|
81
89
|
|
|
82
|
-
logger.info(f"Created {len(tools)} MCP tools: {[t.name for t in
|
|
83
|
-
return
|
|
90
|
+
logger.info(f"Created {len(client.tools)} MCP tools: {[t.name for t in client]}")
|
|
91
|
+
return client
|
openhands/sdk/plugin/__init__.py
CHANGED
|
@@ -7,7 +7,11 @@ It also provides support for plugin marketplaces - directories that list
|
|
|
7
7
|
available plugins with their metadata and source locations.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
from openhands.sdk.plugin.fetch import
|
|
10
|
+
from openhands.sdk.plugin.fetch import (
|
|
11
|
+
PluginFetchError,
|
|
12
|
+
fetch_plugin_with_resolution,
|
|
13
|
+
)
|
|
14
|
+
from openhands.sdk.plugin.loader import load_plugins
|
|
11
15
|
from openhands.sdk.plugin.plugin import Plugin
|
|
12
16
|
from openhands.sdk.plugin.types import (
|
|
13
17
|
AgentDefinition,
|
|
@@ -19,6 +23,8 @@ from openhands.sdk.plugin.types import (
|
|
|
19
23
|
MarketplacePluginSource,
|
|
20
24
|
PluginAuthor,
|
|
21
25
|
PluginManifest,
|
|
26
|
+
PluginSource,
|
|
27
|
+
ResolvedPluginSource,
|
|
22
28
|
)
|
|
23
29
|
|
|
24
30
|
|
|
@@ -28,8 +34,13 @@ __all__ = [
|
|
|
28
34
|
"PluginFetchError",
|
|
29
35
|
"PluginManifest",
|
|
30
36
|
"PluginAuthor",
|
|
37
|
+
"PluginSource",
|
|
38
|
+
"ResolvedPluginSource",
|
|
31
39
|
"AgentDefinition",
|
|
32
40
|
"CommandDefinition",
|
|
41
|
+
# Plugin loading
|
|
42
|
+
"load_plugins",
|
|
43
|
+
"fetch_plugin_with_resolution",
|
|
33
44
|
# Marketplace classes
|
|
34
45
|
"Marketplace",
|
|
35
46
|
"MarketplaceOwner",
|
openhands/sdk/plugin/fetch.py
CHANGED
|
@@ -104,23 +104,22 @@ def get_cache_path(source: str, cache_dir: Path | None = None) -> Path:
|
|
|
104
104
|
return cache_dir / cache_name
|
|
105
105
|
|
|
106
106
|
|
|
107
|
-
def _resolve_local_source(url: str
|
|
107
|
+
def _resolve_local_source(url: str) -> Path:
|
|
108
108
|
"""Resolve a local plugin source to a path.
|
|
109
109
|
|
|
110
110
|
Args:
|
|
111
111
|
url: Local path string (may contain ~ for home directory).
|
|
112
|
-
subpath: Optional subdirectory within the local path.
|
|
113
112
|
|
|
114
113
|
Returns:
|
|
115
114
|
Resolved absolute path to the plugin directory.
|
|
116
115
|
|
|
117
116
|
Raises:
|
|
118
|
-
PluginFetchError: If path doesn't exist
|
|
117
|
+
PluginFetchError: If path doesn't exist.
|
|
119
118
|
"""
|
|
120
119
|
local_path = Path(url).expanduser().resolve()
|
|
121
120
|
if not local_path.exists():
|
|
122
121
|
raise PluginFetchError(f"Local plugin path does not exist: {local_path}")
|
|
123
|
-
return
|
|
122
|
+
return local_path
|
|
124
123
|
|
|
125
124
|
|
|
126
125
|
def _fetch_remote_source(
|
|
@@ -194,38 +193,143 @@ def fetch_plugin(
|
|
|
194
193
|
cache_dir: Path | None = None,
|
|
195
194
|
ref: str | None = None,
|
|
196
195
|
update: bool = True,
|
|
197
|
-
|
|
196
|
+
repo_path: str | None = None,
|
|
198
197
|
git_helper: GitHelper | None = None,
|
|
199
198
|
) -> Path:
|
|
200
199
|
"""Fetch a plugin from a remote source and return the local cached path.
|
|
201
200
|
|
|
202
201
|
Args:
|
|
203
202
|
source: Plugin source - can be:
|
|
204
|
-
-
|
|
205
|
-
|
|
203
|
+
- Any git URL (GitHub, GitLab, Bitbucket, Codeberg, self-hosted, etc.)
|
|
204
|
+
e.g., "https://gitlab.com/org/repo", "git@bitbucket.org:team/repo.git"
|
|
205
|
+
- "github:owner/repo" - GitHub shorthand (convenience syntax)
|
|
206
206
|
- "/local/path" - Local path (returned as-is)
|
|
207
207
|
cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
|
|
208
208
|
ref: Optional branch, tag, or commit to checkout.
|
|
209
209
|
update: If True and cache exists, update it. If False, use cached version as-is.
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
repo_path: Subdirectory path within the git repository
|
|
211
|
+
(e.g., 'plugins/my-plugin' for monorepos). Only relevant for git
|
|
212
|
+
sources, not local paths. If specified, the returned path will
|
|
213
|
+
point to this subdirectory instead of the repository root.
|
|
212
214
|
git_helper: GitHelper instance (for testing). Defaults to global instance.
|
|
213
215
|
|
|
214
216
|
Returns:
|
|
215
217
|
Path to the local plugin directory (ready for Plugin.load()).
|
|
216
|
-
If
|
|
218
|
+
If repo_path is specified, returns the path to that subdirectory.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
PluginFetchError: If fetching fails or repo_path doesn't exist.
|
|
222
|
+
"""
|
|
223
|
+
path, _ = fetch_plugin_with_resolution(
|
|
224
|
+
source=source,
|
|
225
|
+
cache_dir=cache_dir,
|
|
226
|
+
ref=ref,
|
|
227
|
+
update=update,
|
|
228
|
+
repo_path=repo_path,
|
|
229
|
+
git_helper=git_helper,
|
|
230
|
+
)
|
|
231
|
+
return path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def fetch_plugin_with_resolution(
|
|
235
|
+
source: str,
|
|
236
|
+
cache_dir: Path | None = None,
|
|
237
|
+
ref: str | None = None,
|
|
238
|
+
update: bool = True,
|
|
239
|
+
repo_path: str | None = None,
|
|
240
|
+
git_helper: GitHelper | None = None,
|
|
241
|
+
) -> tuple[Path, str | None]:
|
|
242
|
+
"""Fetch a plugin and return both the path and the resolved commit SHA.
|
|
243
|
+
|
|
244
|
+
This is similar to fetch_plugin() but also returns the actual commit SHA
|
|
245
|
+
that was checked out. This is useful for persistence - storing the resolved
|
|
246
|
+
SHA ensures that conversation resume gets exactly the same plugin version.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
source: Plugin source (see fetch_plugin for formats).
|
|
250
|
+
cache_dir: Directory for caching. Defaults to ~/.openhands/cache/plugins/
|
|
251
|
+
ref: Optional branch, tag, or commit to checkout.
|
|
252
|
+
update: If True and cache exists, update it. If False, use cached version as-is.
|
|
253
|
+
repo_path: Subdirectory path within the git repository.
|
|
254
|
+
git_helper: GitHelper instance (for testing). Defaults to global instance.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
Tuple of (path, resolved_ref) where:
|
|
258
|
+
- path: Path to the local plugin directory
|
|
259
|
+
- resolved_ref: Commit SHA that was checked out (None for local sources)
|
|
217
260
|
|
|
218
261
|
Raises:
|
|
219
|
-
PluginFetchError: If fetching fails or
|
|
262
|
+
PluginFetchError: If fetching fails or repo_path doesn't exist.
|
|
220
263
|
"""
|
|
221
264
|
source_type, url = parse_plugin_source(source)
|
|
222
265
|
|
|
223
266
|
if source_type == "local":
|
|
224
|
-
|
|
267
|
+
if repo_path is not None:
|
|
268
|
+
raise PluginFetchError(
|
|
269
|
+
f"repo_path is not supported for local plugin sources. "
|
|
270
|
+
f"Specify the full path directly instead of "
|
|
271
|
+
f"source='{source}' + repo_path='{repo_path}'"
|
|
272
|
+
)
|
|
273
|
+
return _resolve_local_source(url), None
|
|
225
274
|
|
|
226
275
|
if cache_dir is None:
|
|
227
276
|
cache_dir = DEFAULT_CACHE_DIR
|
|
228
277
|
|
|
229
|
-
|
|
230
|
-
|
|
278
|
+
git = git_helper if git_helper is not None else GitHelper()
|
|
279
|
+
|
|
280
|
+
plugin_path, resolved_ref = _fetch_remote_source_with_resolution(
|
|
281
|
+
url, cache_dir, ref, update, repo_path, git, source
|
|
282
|
+
)
|
|
283
|
+
return plugin_path, resolved_ref
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _fetch_remote_source_with_resolution(
|
|
287
|
+
url: str,
|
|
288
|
+
cache_dir: Path,
|
|
289
|
+
ref: str | None,
|
|
290
|
+
update: bool,
|
|
291
|
+
subpath: str | None,
|
|
292
|
+
git_helper: GitHelper,
|
|
293
|
+
source: str,
|
|
294
|
+
) -> tuple[Path, str]:
|
|
295
|
+
"""Fetch a remote plugin source and return path + resolved commit SHA.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
url: Git URL to fetch.
|
|
299
|
+
cache_dir: Base directory for caching.
|
|
300
|
+
ref: Optional branch, tag, or commit to checkout.
|
|
301
|
+
update: Whether to update existing cache.
|
|
302
|
+
subpath: Optional subdirectory within the repository.
|
|
303
|
+
git_helper: GitHelper instance for git operations.
|
|
304
|
+
source: Original source string (for error messages).
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Tuple of (path, resolved_ref) where resolved_ref is the commit SHA.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
PluginFetchError: If fetching fails or subpath is invalid.
|
|
311
|
+
"""
|
|
312
|
+
repo_cache_path = get_cache_path(url, cache_dir)
|
|
313
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
|
|
315
|
+
result = try_cached_clone_or_update(
|
|
316
|
+
url=url,
|
|
317
|
+
repo_path=repo_cache_path,
|
|
318
|
+
ref=ref,
|
|
319
|
+
update=update,
|
|
320
|
+
git_helper=git_helper,
|
|
231
321
|
)
|
|
322
|
+
|
|
323
|
+
if result is None:
|
|
324
|
+
raise PluginFetchError(f"Failed to fetch plugin from {source}")
|
|
325
|
+
|
|
326
|
+
# Get the actual commit SHA that was checked out
|
|
327
|
+
try:
|
|
328
|
+
resolved_ref = git_helper.get_head_commit(repo_cache_path)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.warning(f"Could not get commit SHA for {source}: {e}")
|
|
331
|
+
# Fall back to the requested ref if we can't get the SHA
|
|
332
|
+
resolved_ref = ref or "HEAD"
|
|
333
|
+
|
|
334
|
+
final_path = _apply_subpath(repo_cache_path, subpath, "plugin repository")
|
|
335
|
+
return final_path, resolved_ref
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Plugin loading utility for multi-plugin support.
|
|
2
|
+
|
|
3
|
+
This module provides the canonical function for loading multiple plugins
|
|
4
|
+
and merging them into an agent. It is used by:
|
|
5
|
+
- LocalConversation (for SDK-direct users)
|
|
6
|
+
- ConversationService (for agent-server users)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from openhands.sdk.hooks import HookConfig
|
|
14
|
+
from openhands.sdk.logger import get_logger
|
|
15
|
+
from openhands.sdk.plugin.plugin import Plugin
|
|
16
|
+
from openhands.sdk.plugin.types import PluginSource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from openhands.sdk.agent.base import AgentBase
|
|
21
|
+
from openhands.sdk.context import AgentContext
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_plugins(
|
|
28
|
+
plugin_specs: list[PluginSource],
|
|
29
|
+
agent: AgentBase,
|
|
30
|
+
max_skills: int = 100,
|
|
31
|
+
) -> tuple[AgentBase, HookConfig | None]:
|
|
32
|
+
"""Load multiple plugins and merge them into the agent.
|
|
33
|
+
|
|
34
|
+
This is the canonical function for plugin loading, used by:
|
|
35
|
+
- LocalConversation (for SDK-direct users)
|
|
36
|
+
- ConversationService (for agent-server users)
|
|
37
|
+
|
|
38
|
+
Plugins are loaded in order and their contents are merged with these semantics:
|
|
39
|
+
- Skills: Override by name (last plugin wins)
|
|
40
|
+
- MCP config: Override by key (last plugin wins)
|
|
41
|
+
- Hooks: Concatenate (all hooks run)
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
plugin_specs: List of plugin sources to load.
|
|
45
|
+
agent: Agent to merge plugins into.
|
|
46
|
+
max_skills: Maximum total skills allowed (defense-in-depth limit).
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Tuple of (updated_agent, merged_hook_config).
|
|
50
|
+
The agent has updated agent_context (with merged skills) and mcp_config.
|
|
51
|
+
The hook_config contains all hooks from all plugins concatenated.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
PluginFetchError: If any plugin fails to fetch.
|
|
55
|
+
FileNotFoundError: If any plugin fails to load (e.g., path not found).
|
|
56
|
+
ValueError: If max_skills limit is exceeded.
|
|
57
|
+
|
|
58
|
+
Example:
|
|
59
|
+
>>> from openhands.sdk.plugin import PluginSource
|
|
60
|
+
>>> plugins = [
|
|
61
|
+
... PluginSource(source="github:owner/security-plugin", ref="v1.0.0"),
|
|
62
|
+
... PluginSource(source="/local/custom-plugin"),
|
|
63
|
+
... ]
|
|
64
|
+
>>> updated_agent, hooks = load_plugins(plugins, agent)
|
|
65
|
+
"""
|
|
66
|
+
if not plugin_specs:
|
|
67
|
+
return agent, None
|
|
68
|
+
|
|
69
|
+
# Start with agent's existing context and MCP config
|
|
70
|
+
merged_context: AgentContext | None = agent.agent_context
|
|
71
|
+
merged_mcp: dict[str, Any] = dict(agent.mcp_config) if agent.mcp_config else {}
|
|
72
|
+
all_hooks: list[HookConfig] = []
|
|
73
|
+
|
|
74
|
+
for spec in plugin_specs:
|
|
75
|
+
logger.info(f"Loading plugin from {spec.source}")
|
|
76
|
+
|
|
77
|
+
# Fetch (downloads if needed, returns cached path)
|
|
78
|
+
path = Plugin.fetch(
|
|
79
|
+
source=spec.source,
|
|
80
|
+
ref=spec.ref,
|
|
81
|
+
repo_path=spec.repo_path,
|
|
82
|
+
)
|
|
83
|
+
plugin = Plugin.load(path)
|
|
84
|
+
|
|
85
|
+
logger.info(
|
|
86
|
+
f"Loaded plugin '{plugin.name}': "
|
|
87
|
+
f"{len(plugin.skills)} skills, "
|
|
88
|
+
f"hooks={'yes' if plugin.hooks else 'no'}, "
|
|
89
|
+
f"mcp_config={'yes' if plugin.mcp_config else 'no'}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Merge skills and MCP config separately
|
|
93
|
+
merged_context = plugin.add_skills_to(merged_context, max_skills=max_skills)
|
|
94
|
+
merged_mcp = plugin.add_mcp_config_to(merged_mcp)
|
|
95
|
+
|
|
96
|
+
# Collect hooks for later combination
|
|
97
|
+
if plugin.hooks and not plugin.hooks.is_empty():
|
|
98
|
+
all_hooks.append(plugin.hooks)
|
|
99
|
+
|
|
100
|
+
# Combine all hook configs (concatenation semantics)
|
|
101
|
+
combined_hooks = HookConfig.merge(all_hooks)
|
|
102
|
+
|
|
103
|
+
# Create updated agent with merged content
|
|
104
|
+
updated_agent = agent.model_copy(
|
|
105
|
+
update={
|
|
106
|
+
"agent_context": merged_context,
|
|
107
|
+
"mcp_config": merged_mcp,
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return updated_agent, combined_hooks
|
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
|