openhands-agent-server 1.29.2__tar.gz → 1.29.3__tar.gz
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_agent_server-1.29.2 → openhands_agent_server-1.29.3}/PKG-INFO +1 -1
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/api.py +2 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/conversation_service.py +14 -3
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/event_service.py +14 -12
- openhands_agent_server-1.29.3/openhands/agent_server/plugins_router.py +333 -0
- openhands_agent_server-1.29.3/openhands/agent_server/plugins_service.py +295 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/SOURCES.txt +4 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/pyproject.toml +1 -1
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/agent_profiles_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/config.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/conversation_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/file_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/init_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/middleware.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/models.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openai/__init__.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openai/models.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openai/router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openai/service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/persistence/models.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/profiles_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/skills_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/skills_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.29.
|
|
3
|
+
Version: 1.29.3
|
|
4
4
|
Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/api.py
RENAMED
|
@@ -50,6 +50,7 @@ from openhands.agent_server.openai.router import (
|
|
|
50
50
|
check_openai_api_key,
|
|
51
51
|
openai_router,
|
|
52
52
|
)
|
|
53
|
+
from openhands.agent_server.plugins_router import plugins_router
|
|
53
54
|
from openhands.agent_server.profiles_router import profiles_router
|
|
54
55
|
from openhands.agent_server.server_details_router import (
|
|
55
56
|
get_server_info,
|
|
@@ -348,6 +349,7 @@ def _add_api_routes(app: FastAPI) -> None:
|
|
|
348
349
|
api_router.include_router(vscode_router)
|
|
349
350
|
api_router.include_router(desktop_router)
|
|
350
351
|
api_router.include_router(skills_router)
|
|
352
|
+
api_router.include_router(plugins_router)
|
|
351
353
|
api_router.include_router(hooks_router)
|
|
352
354
|
api_router.include_router(llm_router)
|
|
353
355
|
api_router.include_router(mcp_router)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import importlib
|
|
3
3
|
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
4
5
|
from concurrent.futures import ThreadPoolExecutor
|
|
5
6
|
from contextlib import suppress
|
|
6
7
|
from dataclasses import dataclass, field
|
|
@@ -1367,6 +1368,12 @@ class WebhookSubscriber(Subscriber):
|
|
|
1367
1368
|
session_api_key: str | None = None
|
|
1368
1369
|
queue: list[Event] = field(default_factory=list)
|
|
1369
1370
|
_flush_timer: asyncio.Task | None = field(default=None, init=False)
|
|
1371
|
+
# Per-instance sleep seam so tests override delays without patching the
|
|
1372
|
+
# global asyncio.sleep. default_factory (not default) keeps it an instance
|
|
1373
|
+
# attribute, else the function would be descriptor-bound as a method.
|
|
1374
|
+
_sleep: Callable[[float], Awaitable[None]] = field(
|
|
1375
|
+
default_factory=lambda: asyncio.sleep, init=False
|
|
1376
|
+
)
|
|
1370
1377
|
|
|
1371
1378
|
async def __call__(self, event: Event):
|
|
1372
1379
|
"""Add event to queue and post to webhook when buffer size is reached."""
|
|
@@ -1437,7 +1444,7 @@ class WebhookSubscriber(Subscriber):
|
|
|
1437
1444
|
except Exception as e:
|
|
1438
1445
|
logger.warning(f"Webhook post attempt {attempt + 1} failed: {e}")
|
|
1439
1446
|
if attempt < self.spec.num_retries:
|
|
1440
|
-
await
|
|
1447
|
+
await self._sleep(self.spec.retry_delay)
|
|
1441
1448
|
else:
|
|
1442
1449
|
logger.error(
|
|
1443
1450
|
f"Failed to post events to webhook {events_url} "
|
|
@@ -1462,7 +1469,7 @@ class WebhookSubscriber(Subscriber):
|
|
|
1462
1469
|
async def _flush_after_delay(self):
|
|
1463
1470
|
"""Wait for flush_delay seconds then flush events if any exist."""
|
|
1464
1471
|
try:
|
|
1465
|
-
await
|
|
1472
|
+
await self._sleep(self.spec.flush_delay)
|
|
1466
1473
|
# Only flush if there are events in the queue
|
|
1467
1474
|
if self.queue:
|
|
1468
1475
|
await self._post_events()
|
|
@@ -1479,6 +1486,10 @@ class ConversationWebhookSubscriber:
|
|
|
1479
1486
|
|
|
1480
1487
|
spec: WebhookSpec
|
|
1481
1488
|
session_api_key: str | None = None
|
|
1489
|
+
# Per-instance sleep seam; see WebhookSubscriber._sleep.
|
|
1490
|
+
_sleep: Callable[[float], Awaitable[None]] = field(
|
|
1491
|
+
default_factory=lambda: asyncio.sleep, init=False
|
|
1492
|
+
)
|
|
1482
1493
|
|
|
1483
1494
|
async def post_conversation_info(self, conversation_info: BaseModel):
|
|
1484
1495
|
"""Post conversation info to the webhook immediately (no batching)."""
|
|
@@ -1516,7 +1527,7 @@ class ConversationWebhookSubscriber:
|
|
|
1516
1527
|
f"Conversation webhook post attempt {attempt + 1} failed: {e}"
|
|
1517
1528
|
)
|
|
1518
1529
|
if attempt < self.spec.num_retries:
|
|
1519
|
-
await
|
|
1530
|
+
await self._sleep(self.spec.retry_delay)
|
|
1520
1531
|
else:
|
|
1521
1532
|
# Log response content for debugging failures
|
|
1522
1533
|
response_content = (
|
|
@@ -605,10 +605,9 @@ class EventService:
|
|
|
605
605
|
from callbacks that may run in different threads. Events are emitted through
|
|
606
606
|
the conversation's normal event flow to ensure they are persisted.
|
|
607
607
|
"""
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
608
|
+
main_loop = self._main_loop
|
|
609
|
+
conversation = self._conversation
|
|
610
|
+
if main_loop and main_loop.is_running() and conversation:
|
|
612
611
|
# Wrap _on_event with lock acquisition to ensure thread-safe access
|
|
613
612
|
# to conversation state and event log during concurrent operations
|
|
614
613
|
def locked_on_event():
|
|
@@ -617,7 +616,7 @@ class EventService:
|
|
|
617
616
|
|
|
618
617
|
# Run the locked callback in an executor to ensure the event is
|
|
619
618
|
# both persisted and sent to WebSocket subscribers
|
|
620
|
-
|
|
619
|
+
main_loop.run_in_executor(None, locked_on_event)
|
|
621
620
|
|
|
622
621
|
def _setup_llm_log_streaming(self, agent: AgentBase) -> None:
|
|
623
622
|
"""Configure LLM log callbacks to stream logs via events."""
|
|
@@ -633,13 +632,16 @@ class EventService:
|
|
|
633
632
|
filename: str, log_data: str, uid=usage_id, model=model_name
|
|
634
633
|
) -> None:
|
|
635
634
|
"""Callback to emit LLM completion logs as events."""
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
635
|
+
try:
|
|
636
|
+
event = LLMCompletionLogEvent(
|
|
637
|
+
filename=filename,
|
|
638
|
+
log_data=log_data,
|
|
639
|
+
model_name=model,
|
|
640
|
+
usage_id=uid,
|
|
641
|
+
)
|
|
642
|
+
self._emit_event_from_thread(event)
|
|
643
|
+
except Exception:
|
|
644
|
+
logger.exception("Failed to emit LLM completion log event")
|
|
643
645
|
|
|
644
646
|
llm.telemetry.set_log_completions_callback(log_callback)
|
|
645
647
|
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Plugins router for OpenHands Agent Server.
|
|
2
|
+
|
|
3
|
+
HTTP API endpoints for plugin operations. Business logic is delegated to
|
|
4
|
+
``plugins_service.py``; this module mirrors ``skills_router.py`` and stays
|
|
5
|
+
focused on HTTP concerns. It exposes:
|
|
6
|
+
|
|
7
|
+
* Installed-plugin management — install / list / enable-disable / uninstall /
|
|
8
|
+
refresh — plus listing locally-available plugins.
|
|
9
|
+
* The plugins-only marketplace catalog.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Annotated
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException, Path
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
from openhands.agent_server.plugins_service import (
|
|
18
|
+
MarketplacePluginInfo,
|
|
19
|
+
service_disable_plugin,
|
|
20
|
+
service_enable_plugin,
|
|
21
|
+
service_get_installed_plugin,
|
|
22
|
+
service_get_plugins_marketplace_catalog,
|
|
23
|
+
service_install_plugin,
|
|
24
|
+
service_list_available_plugins,
|
|
25
|
+
service_list_installed_plugins,
|
|
26
|
+
service_uninstall_plugin,
|
|
27
|
+
service_update_plugin,
|
|
28
|
+
)
|
|
29
|
+
from openhands.sdk.extensions.fetch import ExtensionFetchError
|
|
30
|
+
from openhands.sdk.plugin import InstalledPluginInfo, PluginFetchError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
plugins_router = APIRouter(prefix="/plugins", tags=["Plugins"])
|
|
34
|
+
|
|
35
|
+
# Kebab-case plugin name — matches the SDK's installed-plugin name rule. Guards
|
|
36
|
+
# the {plugin_name} path parameter against empty strings, path traversal, and
|
|
37
|
+
# invalid characters.
|
|
38
|
+
PLUGIN_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
39
|
+
|
|
40
|
+
PluginNamePath = Annotated[
|
|
41
|
+
str,
|
|
42
|
+
Path(
|
|
43
|
+
min_length=1,
|
|
44
|
+
max_length=255,
|
|
45
|
+
pattern=PLUGIN_NAME_PATTERN,
|
|
46
|
+
description="Plugin name (lowercase alphanumeric, hyphens)",
|
|
47
|
+
),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Models
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PluginsRequest(BaseModel):
|
|
57
|
+
"""Request body for listing locally-available plugins."""
|
|
58
|
+
|
|
59
|
+
load_user: bool = Field(
|
|
60
|
+
default=True, description="Load user plugins (~/.agents/plugins, etc.)"
|
|
61
|
+
)
|
|
62
|
+
load_project: bool = Field(
|
|
63
|
+
default=True, description="Load project plugins from the workspace"
|
|
64
|
+
)
|
|
65
|
+
project_dir: str | None = Field(
|
|
66
|
+
default=None, description="Workspace directory path for project plugins"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PluginInfo(BaseModel):
|
|
71
|
+
"""Summary of an available plugin."""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
version: str = ""
|
|
75
|
+
description: str = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PluginsResponse(BaseModel):
|
|
79
|
+
"""Response containing the locally-available plugins."""
|
|
80
|
+
|
|
81
|
+
plugins: list[PluginInfo]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class InstallPluginRequest(BaseModel):
|
|
85
|
+
"""Request body for installing a plugin."""
|
|
86
|
+
|
|
87
|
+
source: str = Field(
|
|
88
|
+
min_length=1,
|
|
89
|
+
description=(
|
|
90
|
+
"Plugin source - git URL, GitHub shorthand, or local path. Examples: "
|
|
91
|
+
"'github:OpenHands/extensions/plugins/city-weather', '/path/to/plugin'"
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
ref: str | None = Field(
|
|
95
|
+
default=None, description="Optional branch, tag, or commit to install"
|
|
96
|
+
)
|
|
97
|
+
repo_path: str | None = Field(
|
|
98
|
+
default=None,
|
|
99
|
+
description="Subdirectory path within the repository (for monorepos)",
|
|
100
|
+
)
|
|
101
|
+
force: bool = Field(
|
|
102
|
+
default=False, description="If true, overwrite existing installation"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InstalledPluginResponse(BaseModel):
|
|
107
|
+
"""Response containing installed plugin information."""
|
|
108
|
+
|
|
109
|
+
name: str = Field(description="Plugin name")
|
|
110
|
+
version: str = Field(default="", description="Plugin version")
|
|
111
|
+
description: str = Field(default="", description="Plugin description")
|
|
112
|
+
enabled: bool = Field(default=True, description="Whether the plugin is enabled")
|
|
113
|
+
source: str = Field(description="Original source (e.g., 'github:owner/repo')")
|
|
114
|
+
resolved_ref: str | None = Field(
|
|
115
|
+
default=None, description="Resolved git commit SHA"
|
|
116
|
+
)
|
|
117
|
+
repo_path: str | None = Field(
|
|
118
|
+
default=None, description="Subdirectory path within the repository"
|
|
119
|
+
)
|
|
120
|
+
installed_at: str = Field(description="ISO 8601 timestamp of installation")
|
|
121
|
+
install_path: str = Field(description="Path where the plugin is installed")
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_plugin_info(cls, info: InstalledPluginInfo) -> "InstalledPluginResponse":
|
|
125
|
+
return cls(
|
|
126
|
+
name=info.name,
|
|
127
|
+
version=info.version,
|
|
128
|
+
description=info.description,
|
|
129
|
+
enabled=info.enabled,
|
|
130
|
+
source=info.source,
|
|
131
|
+
resolved_ref=info.resolved_ref,
|
|
132
|
+
repo_path=info.repo_path,
|
|
133
|
+
installed_at=info.installed_at,
|
|
134
|
+
install_path=str(info.install_path),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class InstalledPluginsListResponse(BaseModel):
|
|
139
|
+
"""Response containing the list of installed plugins."""
|
|
140
|
+
|
|
141
|
+
plugins: list[InstalledPluginResponse]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class UpdatePluginStateRequest(BaseModel):
|
|
145
|
+
"""Request body for updating plugin state (enable/disable)."""
|
|
146
|
+
|
|
147
|
+
enabled: bool
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class UpdatePluginStateResponse(BaseModel):
|
|
151
|
+
"""Response from a plugin state update."""
|
|
152
|
+
|
|
153
|
+
name: str
|
|
154
|
+
enabled: bool
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class UninstallPluginResponse(BaseModel):
|
|
158
|
+
"""Response from a plugin uninstall."""
|
|
159
|
+
|
|
160
|
+
message: str
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class UpdatePluginResponse(BaseModel):
|
|
164
|
+
"""Response from a plugin refresh/update."""
|
|
165
|
+
|
|
166
|
+
message: str
|
|
167
|
+
plugin: InstalledPluginResponse
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class MarketplaceCatalogResponse(BaseModel):
|
|
171
|
+
"""Response containing the plugins marketplace catalog."""
|
|
172
|
+
|
|
173
|
+
plugins: list[MarketplacePluginInfo]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
# Endpoints
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@plugins_router.post("", response_model=PluginsResponse)
|
|
182
|
+
def get_plugins(request: PluginsRequest) -> PluginsResponse:
|
|
183
|
+
"""List locally-available plugins (enabled installed + user/project dirs).
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
request: Which local sources to load.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
PluginsResponse with available plugin summaries.
|
|
190
|
+
"""
|
|
191
|
+
plugins = service_list_available_plugins(
|
|
192
|
+
load_user=request.load_user,
|
|
193
|
+
load_project=request.load_project,
|
|
194
|
+
project_dir=request.project_dir,
|
|
195
|
+
)
|
|
196
|
+
return PluginsResponse(
|
|
197
|
+
plugins=[
|
|
198
|
+
PluginInfo(name=p.name, version=p.version, description=p.description)
|
|
199
|
+
for p in plugins
|
|
200
|
+
]
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@plugins_router.post(
|
|
205
|
+
"/install",
|
|
206
|
+
response_model=InstalledPluginResponse,
|
|
207
|
+
responses={
|
|
208
|
+
400: {"description": "Failed to fetch plugin source"},
|
|
209
|
+
409: {"description": "Plugin already installed (use force=true)"},
|
|
210
|
+
422: {"description": "Invalid plugin (bad name, etc.)"},
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
def install_plugin_endpoint(request: InstallPluginRequest) -> InstalledPluginResponse:
|
|
214
|
+
"""Install a plugin from a git URL, GitHub shorthand, or local path."""
|
|
215
|
+
try:
|
|
216
|
+
info = service_install_plugin(
|
|
217
|
+
source=request.source,
|
|
218
|
+
ref=request.ref,
|
|
219
|
+
repo_path=request.repo_path,
|
|
220
|
+
force=request.force,
|
|
221
|
+
)
|
|
222
|
+
return InstalledPluginResponse.from_plugin_info(info)
|
|
223
|
+
except FileExistsError:
|
|
224
|
+
raise HTTPException(
|
|
225
|
+
status_code=409,
|
|
226
|
+
detail="Plugin already installed. Use force=true to overwrite.",
|
|
227
|
+
)
|
|
228
|
+
except (PluginFetchError, ExtensionFetchError):
|
|
229
|
+
raise HTTPException(
|
|
230
|
+
status_code=400,
|
|
231
|
+
detail="Failed to fetch plugin source. Check that the source is valid.",
|
|
232
|
+
)
|
|
233
|
+
except ValueError:
|
|
234
|
+
raise HTTPException(
|
|
235
|
+
status_code=422,
|
|
236
|
+
detail="Invalid plugin. Ensure it has a valid kebab-case name.",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@plugins_router.get("/installed", response_model=InstalledPluginsListResponse)
|
|
241
|
+
def list_installed_plugins_endpoint() -> InstalledPluginsListResponse:
|
|
242
|
+
"""List all installed plugins (enabled and disabled)."""
|
|
243
|
+
plugins = service_list_installed_plugins()
|
|
244
|
+
return InstalledPluginsListResponse(
|
|
245
|
+
plugins=[InstalledPluginResponse.from_plugin_info(info) for info in plugins]
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@plugins_router.get(
|
|
250
|
+
"/installed/{plugin_name}",
|
|
251
|
+
response_model=InstalledPluginResponse,
|
|
252
|
+
responses={404: {"description": "Plugin not installed"}},
|
|
253
|
+
)
|
|
254
|
+
def get_installed_plugin_endpoint(
|
|
255
|
+
plugin_name: PluginNamePath,
|
|
256
|
+
) -> InstalledPluginResponse:
|
|
257
|
+
"""Get information about a specific installed plugin."""
|
|
258
|
+
info = service_get_installed_plugin(name=plugin_name)
|
|
259
|
+
if info is None:
|
|
260
|
+
raise HTTPException(
|
|
261
|
+
status_code=404,
|
|
262
|
+
detail=f"Plugin '{plugin_name}' is not installed",
|
|
263
|
+
)
|
|
264
|
+
return InstalledPluginResponse.from_plugin_info(info)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@plugins_router.patch(
|
|
268
|
+
"/installed/{plugin_name}",
|
|
269
|
+
response_model=UpdatePluginStateResponse,
|
|
270
|
+
responses={404: {"description": "Plugin not installed"}},
|
|
271
|
+
)
|
|
272
|
+
def set_plugin_enabled_endpoint(
|
|
273
|
+
plugin_name: PluginNamePath, request: UpdatePluginStateRequest
|
|
274
|
+
) -> UpdatePluginStateResponse:
|
|
275
|
+
"""Enable or disable an installed plugin."""
|
|
276
|
+
fn = service_enable_plugin if request.enabled else service_disable_plugin
|
|
277
|
+
if not fn(name=plugin_name):
|
|
278
|
+
raise HTTPException(
|
|
279
|
+
status_code=404,
|
|
280
|
+
detail=f"Plugin '{plugin_name}' is not installed",
|
|
281
|
+
)
|
|
282
|
+
return UpdatePluginStateResponse(name=plugin_name, enabled=request.enabled)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@plugins_router.delete(
|
|
286
|
+
"/installed/{plugin_name}",
|
|
287
|
+
response_model=UninstallPluginResponse,
|
|
288
|
+
responses={404: {"description": "Plugin not installed"}},
|
|
289
|
+
)
|
|
290
|
+
def uninstall_plugin_endpoint(plugin_name: PluginNamePath) -> UninstallPluginResponse:
|
|
291
|
+
"""Uninstall a plugin by name."""
|
|
292
|
+
if not service_uninstall_plugin(name=plugin_name):
|
|
293
|
+
raise HTTPException(
|
|
294
|
+
status_code=404,
|
|
295
|
+
detail=f"Plugin '{plugin_name}' is not installed",
|
|
296
|
+
)
|
|
297
|
+
return UninstallPluginResponse(message=f"Plugin '{plugin_name}' uninstalled")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@plugins_router.post(
|
|
301
|
+
"/installed/{plugin_name}/refresh",
|
|
302
|
+
response_model=UpdatePluginResponse,
|
|
303
|
+
responses={404: {"description": "Plugin not installed"}},
|
|
304
|
+
)
|
|
305
|
+
def refresh_plugin_endpoint(plugin_name: PluginNamePath) -> UpdatePluginResponse:
|
|
306
|
+
"""Refresh an installed plugin to the latest version from its source."""
|
|
307
|
+
info = service_update_plugin(name=plugin_name)
|
|
308
|
+
if info is None:
|
|
309
|
+
raise HTTPException(
|
|
310
|
+
status_code=404,
|
|
311
|
+
detail=f"Plugin '{plugin_name}' is not installed",
|
|
312
|
+
)
|
|
313
|
+
return UpdatePluginResponse(
|
|
314
|
+
message=f"Plugin '{plugin_name}' updated",
|
|
315
|
+
plugin=InstalledPluginResponse.from_plugin_info(info),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@plugins_router.get("/marketplace", response_model=MarketplaceCatalogResponse)
|
|
320
|
+
def get_marketplace_catalog() -> MarketplaceCatalogResponse:
|
|
321
|
+
"""Get the plugins marketplace catalog with installation status.
|
|
322
|
+
|
|
323
|
+
Returns the true plugins (entries whose source lives under ``./plugins/``)
|
|
324
|
+
from the OpenHands extensions repository marketplace, each with attachable
|
|
325
|
+
``PluginSource`` coordinates (``source`` / ``ref`` / ``repo_path``) and an
|
|
326
|
+
``installed`` flag. This enables the front-end to render a plugins
|
|
327
|
+
marketplace with install/installed state and to attach plugins to
|
|
328
|
+
conversations.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
MarketplaceCatalogResponse containing the list of available plugins.
|
|
332
|
+
"""
|
|
333
|
+
return MarketplaceCatalogResponse(plugins=service_get_plugins_marketplace_catalog())
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Plugins service for OpenHands Agent Server.
|
|
2
|
+
|
|
3
|
+
Business logic for two related concerns, both mirroring their skills
|
|
4
|
+
counterparts (``skills_service.py``) so the router stays focused on HTTP:
|
|
5
|
+
|
|
6
|
+
* Installed-plugin management — thin wrappers over the SDK's installed-plugins
|
|
7
|
+
subsystem (``openhands.sdk.plugin``) — plus listing the locally-available
|
|
8
|
+
plugins.
|
|
9
|
+
* The *plugins-only* marketplace catalog. It returns only true plugins from the
|
|
10
|
+
OpenHands extensions marketplace — entries whose ``source`` lives under
|
|
11
|
+
``./plugins/`` — each carrying attachable ``PluginSource`` coordinates
|
|
12
|
+
(``source`` / ``ref`` / ``repo_path``) plus an ``installed`` flag, so the
|
|
13
|
+
front-end can drive both *attach* and *install* and show install state.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from time import monotonic
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ValidationError
|
|
21
|
+
|
|
22
|
+
from openhands.sdk.logger import get_logger
|
|
23
|
+
from openhands.sdk.marketplace import Marketplace
|
|
24
|
+
from openhands.sdk.plugin import (
|
|
25
|
+
InstalledPluginInfo,
|
|
26
|
+
Plugin,
|
|
27
|
+
disable_plugin,
|
|
28
|
+
enable_plugin,
|
|
29
|
+
get_installed_plugin,
|
|
30
|
+
install_plugin,
|
|
31
|
+
list_installed_plugins,
|
|
32
|
+
uninstall_plugin,
|
|
33
|
+
update_plugin,
|
|
34
|
+
)
|
|
35
|
+
from openhands.sdk.skills.skill import (
|
|
36
|
+
DEFAULT_MARKETPLACE_PATH,
|
|
37
|
+
PUBLIC_SKILLS_REF,
|
|
38
|
+
PUBLIC_SKILLS_REPO,
|
|
39
|
+
)
|
|
40
|
+
from openhands.sdk.skills.utils import (
|
|
41
|
+
get_skills_cache_dir,
|
|
42
|
+
update_skills_repository,
|
|
43
|
+
)
|
|
44
|
+
from openhands.sdk.utils.path import to_posix_path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
logger = get_logger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Installed-plugin management
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def service_install_plugin(
|
|
56
|
+
source: str,
|
|
57
|
+
ref: str | None = None,
|
|
58
|
+
repo_path: str | None = None,
|
|
59
|
+
force: bool = False,
|
|
60
|
+
installed_dir: Path | None = None,
|
|
61
|
+
) -> InstalledPluginInfo:
|
|
62
|
+
"""Install a plugin from a source into the installed-plugins directory."""
|
|
63
|
+
return install_plugin(
|
|
64
|
+
source=source,
|
|
65
|
+
ref=ref,
|
|
66
|
+
repo_path=repo_path,
|
|
67
|
+
force=force,
|
|
68
|
+
installed_dir=installed_dir,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def service_uninstall_plugin(name: str, installed_dir: Path | None = None) -> bool:
|
|
73
|
+
"""Uninstall a plugin by name. Returns False if it wasn't installed."""
|
|
74
|
+
return uninstall_plugin(name, installed_dir=installed_dir)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def service_enable_plugin(name: str, installed_dir: Path | None = None) -> bool:
|
|
78
|
+
"""Enable an installed plugin. Returns False if it isn't installed."""
|
|
79
|
+
return enable_plugin(name, installed_dir=installed_dir)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def service_disable_plugin(name: str, installed_dir: Path | None = None) -> bool:
|
|
83
|
+
"""Disable an installed plugin. Returns False if it isn't installed."""
|
|
84
|
+
return disable_plugin(name, installed_dir=installed_dir)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def service_list_installed_plugins(
|
|
88
|
+
installed_dir: Path | None = None,
|
|
89
|
+
) -> list[InstalledPluginInfo]:
|
|
90
|
+
"""List all installed plugins (enabled and disabled)."""
|
|
91
|
+
return list_installed_plugins(installed_dir=installed_dir)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def service_get_installed_plugin(
|
|
95
|
+
name: str, installed_dir: Path | None = None
|
|
96
|
+
) -> InstalledPluginInfo | None:
|
|
97
|
+
"""Get a specific installed plugin, or None if it isn't installed."""
|
|
98
|
+
return get_installed_plugin(name, installed_dir=installed_dir)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def service_update_plugin(
|
|
102
|
+
name: str, installed_dir: Path | None = None
|
|
103
|
+
) -> InstalledPluginInfo | None:
|
|
104
|
+
"""Update an installed plugin, or None if it isn't installed."""
|
|
105
|
+
return update_plugin(name, installed_dir=installed_dir)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def service_list_available_plugins(
|
|
109
|
+
load_user: bool = True,
|
|
110
|
+
load_project: bool = True,
|
|
111
|
+
project_dir: str | None = None,
|
|
112
|
+
) -> list[Plugin]:
|
|
113
|
+
"""List locally-available plugins (enabled installed + user/project dirs).
|
|
114
|
+
|
|
115
|
+
``load_available_plugins`` is provided by the "Wire installed + local plugin
|
|
116
|
+
auto-load" ticket (``openhands.sdk.plugin.discovery``). It is imported lazily
|
|
117
|
+
so this module imports cleanly before that ticket is merged; this endpoint
|
|
118
|
+
becomes functional once it lands.
|
|
119
|
+
"""
|
|
120
|
+
from openhands.sdk.plugin import load_available_plugins # type: ignore
|
|
121
|
+
|
|
122
|
+
available = load_available_plugins(
|
|
123
|
+
work_dir=project_dir,
|
|
124
|
+
include_user=load_user,
|
|
125
|
+
include_project=load_project,
|
|
126
|
+
)
|
|
127
|
+
return list(available.values())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# Plugins-only marketplace catalog
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
# The OpenHands extensions marketplace lists both skills and true plugins under
|
|
135
|
+
# its ``plugins`` array, distinguished only by the entry's source path: true
|
|
136
|
+
# plugins live under ``./plugins/`` while skills live under ``./skills/``. We
|
|
137
|
+
# filter on the raw source for this reason (NOT plugin.json presence, which
|
|
138
|
+
# skills carry too).
|
|
139
|
+
_PLUGINS_SOURCE_PREFIX = "./plugins/"
|
|
140
|
+
# Equivalent prefix when an entry uses a structured source object (github/url)
|
|
141
|
+
# carrying an explicit subpath.
|
|
142
|
+
_PLUGINS_SUBPATH_PREFIX = "plugins/"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class MarketplacePluginInfo(BaseModel):
|
|
146
|
+
"""A true plugin in the marketplace catalog, with attach coordinates."""
|
|
147
|
+
|
|
148
|
+
name: str
|
|
149
|
+
description: str | None
|
|
150
|
+
source: str
|
|
151
|
+
ref: str | None = None
|
|
152
|
+
repo_path: str | None = None
|
|
153
|
+
installed: bool
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Marketplace catalog cache
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# Mirrors the skills marketplace cache: each call would otherwise trigger a git
|
|
160
|
+
# fetch (network-bound, multiple seconds). A short TTL avoids that on every tab
|
|
161
|
+
# open. Only the catalog structure is cached; ``installed`` is always derived
|
|
162
|
+
# fresh from the local FS. Unlike the skills cache, an *empty* result (e.g. a
|
|
163
|
+
# transient fetch failure) is NOT cached, so one flaky fetch does not blank the
|
|
164
|
+
# catalog for the whole TTL.
|
|
165
|
+
#
|
|
166
|
+
# Type: (timestamp, list-of-(name, description, source, ref, repo_path)) or None
|
|
167
|
+
_PluginCatalogEntry = tuple[str, str | None, str, str | None, str | None]
|
|
168
|
+
_plugin_catalog_cache: tuple[float, list[_PluginCatalogEntry]] | None = None
|
|
169
|
+
_PLUGIN_CATALOG_TTL_SECONDS = 300 # 5 minutes
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def service_get_plugins_marketplace_catalog(
|
|
173
|
+
marketplace_path: str = DEFAULT_MARKETPLACE_PATH,
|
|
174
|
+
installed_dir: Path | None = None,
|
|
175
|
+
) -> list[MarketplacePluginInfo]:
|
|
176
|
+
"""Get the plugins-only marketplace catalog with installation status.
|
|
177
|
+
|
|
178
|
+
Loads the marketplace JSON from the public extensions repository, keeps only
|
|
179
|
+
true plugins (source under ``./plugins/``), and enriches each with its
|
|
180
|
+
attachable coordinates and installation status.
|
|
181
|
+
|
|
182
|
+
The catalog structure is cached for ``_PLUGIN_CATALOG_TTL_SECONDS`` to avoid
|
|
183
|
+
a git fetch on every call. The ``installed`` field is always resolved fresh
|
|
184
|
+
from the local FS.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
marketplace_path: Relative path to the marketplace JSON file.
|
|
188
|
+
installed_dir: Directory of installed plugins to check status against.
|
|
189
|
+
Defaults to ``~/.openhands/plugins/installed/``.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
List of MarketplacePluginInfo with plugin details and install status.
|
|
193
|
+
"""
|
|
194
|
+
global _plugin_catalog_cache
|
|
195
|
+
|
|
196
|
+
now = monotonic()
|
|
197
|
+
if (
|
|
198
|
+
_plugin_catalog_cache is not None
|
|
199
|
+
and now - _plugin_catalog_cache[0] < _PLUGIN_CATALOG_TTL_SECONDS
|
|
200
|
+
):
|
|
201
|
+
entries = _plugin_catalog_cache[1]
|
|
202
|
+
else:
|
|
203
|
+
entries = _fetch_plugin_catalog_entries(marketplace_path)
|
|
204
|
+
# Only cache non-empty results so a transient fetch failure does not
|
|
205
|
+
# blank the catalog for the whole TTL.
|
|
206
|
+
if entries:
|
|
207
|
+
_plugin_catalog_cache = (now, entries)
|
|
208
|
+
|
|
209
|
+
# Always-fresh installed check — local FS scan, not a network call.
|
|
210
|
+
installed_names = {
|
|
211
|
+
p.name for p in list_installed_plugins(installed_dir=installed_dir)
|
|
212
|
+
}
|
|
213
|
+
return [
|
|
214
|
+
MarketplacePluginInfo(
|
|
215
|
+
name=name,
|
|
216
|
+
description=desc,
|
|
217
|
+
source=src,
|
|
218
|
+
ref=ref,
|
|
219
|
+
repo_path=repo_path,
|
|
220
|
+
installed=name in installed_names,
|
|
221
|
+
)
|
|
222
|
+
for name, desc, src, ref, repo_path in entries
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _is_true_plugin(raw_source: object) -> bool:
|
|
227
|
+
"""Whether a marketplace entry's *raw* source points at a true plugin.
|
|
228
|
+
|
|
229
|
+
Must run on the raw source BEFORE ``resolve_plugin_source`` rewrites a
|
|
230
|
+
relative path into an absolute one (which drops the ``./plugins/`` prefix).
|
|
231
|
+
String sources are true plugins when under ``./plugins/``; structured
|
|
232
|
+
source objects when their subpath is under ``plugins/``. Skills (``./skills/``)
|
|
233
|
+
are excluded.
|
|
234
|
+
"""
|
|
235
|
+
if isinstance(raw_source, str):
|
|
236
|
+
return raw_source.startswith(_PLUGINS_SOURCE_PREFIX)
|
|
237
|
+
subpath = getattr(raw_source, "path", None) or ""
|
|
238
|
+
return subpath.startswith(_PLUGINS_SUBPATH_PREFIX)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _fetch_plugin_catalog_entries(marketplace_path: str) -> list[_PluginCatalogEntry]:
|
|
242
|
+
"""Fetch the marketplace and keep only true plugins.
|
|
243
|
+
|
|
244
|
+
Slow path: git fetch + read the marketplace JSON. Returns
|
|
245
|
+
``(name, description, source, ref, repo_path)`` tuples, or an empty list on
|
|
246
|
+
error.
|
|
247
|
+
"""
|
|
248
|
+
cache_dir = get_skills_cache_dir()
|
|
249
|
+
repo_path = update_skills_repository(
|
|
250
|
+
PUBLIC_SKILLS_REPO, PUBLIC_SKILLS_REF, cache_dir
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if repo_path is None:
|
|
254
|
+
logger.warning("Failed to access public extensions repository")
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
# Primary loader: ``Marketplace.load`` discovers the manifest in
|
|
258
|
+
# ``.plugin/`` or ``.claude-plugin/`` — the real OpenHands/extensions layout,
|
|
259
|
+
# where ``.plugin/marketplace.json`` points at the published catalog. We must
|
|
260
|
+
# NOT gate on ``marketplace_path`` (``marketplaces/default.json``) existing
|
|
261
|
+
# first: that file is absent in the current extensions repo, so an early
|
|
262
|
+
# return there would blank the catalog even though the manifest is present.
|
|
263
|
+
# The explicit ``marketplace_path`` file is only a fallback for layouts that
|
|
264
|
+
# ship it instead of a ``.plugin/`` manifest.
|
|
265
|
+
try:
|
|
266
|
+
marketplace = Marketplace.load(repo_path)
|
|
267
|
+
except (FileNotFoundError, ValueError) as e:
|
|
268
|
+
marketplace_file = repo_path / marketplace_path
|
|
269
|
+
if not marketplace_file.exists():
|
|
270
|
+
logger.warning(
|
|
271
|
+
f"Failed to load marketplace via manifest discovery ({e}); "
|
|
272
|
+
f"fallback file not found: {marketplace_file}"
|
|
273
|
+
)
|
|
274
|
+
return []
|
|
275
|
+
try:
|
|
276
|
+
with open(marketplace_file, encoding="utf-8") as f:
|
|
277
|
+
data = json.load(f)
|
|
278
|
+
marketplace = Marketplace.model_validate(
|
|
279
|
+
{**data, "path": to_posix_path(repo_path)}
|
|
280
|
+
)
|
|
281
|
+
except (json.JSONDecodeError, ValidationError, OSError) as e2:
|
|
282
|
+
logger.warning(f"Failed to load marketplace: {e}, {e2}")
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
entries: list[_PluginCatalogEntry] = []
|
|
286
|
+
for plugin in marketplace.plugins:
|
|
287
|
+
if not _is_true_plugin(plugin.source):
|
|
288
|
+
continue
|
|
289
|
+
# Resolve to attachable coordinates. For a local ./plugins/<name> entry
|
|
290
|
+
# this yields an absolute path with ref/repo_path None; structured
|
|
291
|
+
# github/url sources yield their ref + subpath.
|
|
292
|
+
source, ref, repo_path = marketplace.resolve_plugin_source(plugin)
|
|
293
|
+
entries.append((plugin.name, plugin.description, source, ref, repo_path))
|
|
294
|
+
|
|
295
|
+
return entries
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.29.
|
|
3
|
+
Version: 1.29.3
|
|
4
4
|
Summary: OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent
|
|
5
5
|
Project-URL: Source, https://github.com/OpenHands/software-agent-sdk
|
|
6
6
|
Project-URL: Homepage, https://github.com/OpenHands/software-agent-sdk
|
|
@@ -28,6 +28,8 @@ pyproject.toml
|
|
|
28
28
|
./openhands/agent_server/middleware.py
|
|
29
29
|
./openhands/agent_server/models.py
|
|
30
30
|
./openhands/agent_server/openapi.py
|
|
31
|
+
./openhands/agent_server/plugins_router.py
|
|
32
|
+
./openhands/agent_server/plugins_service.py
|
|
31
33
|
./openhands/agent_server/profiles_router.py
|
|
32
34
|
./openhands/agent_server/pub_sub.py
|
|
33
35
|
./openhands/agent_server/py.typed
|
|
@@ -84,6 +86,8 @@ openhands/agent_server/mcp_router.py
|
|
|
84
86
|
openhands/agent_server/middleware.py
|
|
85
87
|
openhands/agent_server/models.py
|
|
86
88
|
openhands/agent_server/openapi.py
|
|
89
|
+
openhands/agent_server/plugins_router.py
|
|
90
|
+
openhands/agent_server/plugins_service.py
|
|
87
91
|
openhands/agent_server/profiles_router.py
|
|
88
92
|
openhands/agent_server/pub_sub.py
|
|
89
93
|
openhands/agent_server/py.typed
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/__init__.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/env_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/git_router.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/llm_router.py
RENAMED
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/mcp_router.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/middleware.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/models.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/openapi.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/pub_sub.py
RENAMED
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/sockets.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.29.3}/openhands/agent_server/utils.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|