openhands-agent-server 1.29.2__tar.gz → 1.30.0__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.30.0}/PKG-INFO +1 -1
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/agent_profiles_router.py +79 -102
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/api.py +4 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/config.py +23 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_router.py +42 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_service.py +21 -4
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/event_service.py +34 -18
- openhands_agent_server-1.30.0/openhands/agent_server/file_router.py +989 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/router.py +59 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/service.py +3 -0
- openhands_agent_server-1.30.0/openhands/agent_server/plugins_router.py +333 -0
- openhands_agent_server-1.30.0/openhands/agent_server/plugins_service.py +295 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/skills_router.py +33 -2
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/skills_service.py +67 -5
- openhands_agent_server-1.30.0/openhands/agent_server/sub_agents_router.py +130 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/SOURCES.txt +6 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/pyproject.toml +1 -1
- openhands_agent_server-1.29.2/openhands/agent_server/file_router.py +0 -333
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/__init__.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/__main__.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/_secrets_exposure.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/auth_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/bash_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/bash_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_lease.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/dependencies.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/desktop_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/desktop_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/Dockerfile +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/build.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/env_parser.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/event_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/git_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/hooks_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/hooks_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/init_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/llm_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/logging_config.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/mcp_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/middleware.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/models.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/__init__.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/models.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openapi.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/__init__.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/models.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/store.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/profiles_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/pub_sub.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/py.typed +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/server_details_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/settings_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/sockets.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/tool_preload_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/tool_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/utils.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_service.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/workspace_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/workspaces_router.py +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/requires.txt +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
- {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openhands-agent-server
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.30.0
|
|
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
|
|
@@ -12,11 +12,9 @@ MCP references and returns :class:`~openhands.sdk.profiles.AgentProfileDiagnosti
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
import copy
|
|
15
|
-
import shlex
|
|
16
15
|
from collections.abc import Iterator
|
|
17
16
|
from contextlib import contextmanager
|
|
18
17
|
from typing import Annotated, Any
|
|
19
|
-
from uuid import UUID, uuid4
|
|
20
18
|
|
|
21
19
|
from fastapi import APIRouter, HTTPException, Path, Request, status
|
|
22
20
|
from pydantic import BaseModel, Field, ValidationError
|
|
@@ -34,20 +32,26 @@ from openhands.agent_server.persistence import (
|
|
|
34
32
|
get_llm_profile_store,
|
|
35
33
|
get_settings_store,
|
|
36
34
|
)
|
|
35
|
+
from openhands.agent_server.profiles_router import MAX_PROFILES
|
|
36
|
+
from openhands.sdk.llm import LLM
|
|
37
|
+
from openhands.sdk.llm.llm_profile_store import (
|
|
38
|
+
ProfileLimitExceeded as LLMProfileLimitExceeded,
|
|
39
|
+
)
|
|
37
40
|
from openhands.sdk.logger import get_logger
|
|
38
41
|
from openhands.sdk.profiles import (
|
|
42
|
+
SEED_PROFILE_NAME,
|
|
39
43
|
ACPAgentProfile,
|
|
40
44
|
AgentProfileDiagnostics,
|
|
41
45
|
AgentProfileStore,
|
|
42
46
|
OpenHandsAgentProfile,
|
|
43
47
|
ProfileLimitExceeded,
|
|
44
|
-
|
|
48
|
+
build_seed_profile,
|
|
45
49
|
resolve_agent_profile_dry_run,
|
|
50
|
+
safe_validation_error_detail,
|
|
51
|
+
save_profile_preserving_identity,
|
|
46
52
|
validate_agent_profile,
|
|
47
53
|
)
|
|
48
54
|
from openhands.sdk.profiles.agent_profile_store import PROFILE_NAME_PATTERN
|
|
49
|
-
from openhands.sdk.settings import AgentSettingsConfig
|
|
50
|
-
from openhands.sdk.settings.model import VerificationSettings
|
|
51
55
|
from openhands.sdk.utils.cipher import Cipher
|
|
52
56
|
from openhands.sdk.utils.pydantic_secrets import decrypt_str_with_cipher_or_keep
|
|
53
57
|
|
|
@@ -58,9 +62,6 @@ agent_profiles_router = APIRouter(prefix="/agent-profiles", tags=["Agent Profile
|
|
|
58
62
|
|
|
59
63
|
MAX_AGENT_PROFILES = 50
|
|
60
64
|
|
|
61
|
-
# Name the lazily-seeded migration profile (and its LLM ref fallback).
|
|
62
|
-
SEED_PROFILE_NAME = "default"
|
|
63
|
-
|
|
64
65
|
ProfileName = Annotated[
|
|
65
66
|
str,
|
|
66
67
|
Path(min_length=1, max_length=64, pattern=PROFILE_NAME_PATTERN),
|
|
@@ -179,65 +180,57 @@ def _decrypt_profile_mcp_tools(
|
|
|
179
180
|
return profile.model_copy(update={"skills": new_skills})
|
|
180
181
|
|
|
181
182
|
|
|
182
|
-
def
|
|
183
|
-
"""
|
|
184
|
-
|
|
185
|
-
Drops ``critic_api_key`` — the profile is secret-free; the critic reuses
|
|
186
|
-
the resolved LLM profile's key.
|
|
187
|
-
"""
|
|
188
|
-
return ProfileVerificationSettings(
|
|
189
|
-
critic_enabled=v.critic_enabled,
|
|
190
|
-
critic_mode=v.critic_mode,
|
|
191
|
-
enable_iterative_refinement=v.enable_iterative_refinement,
|
|
192
|
-
critic_threshold=v.critic_threshold,
|
|
193
|
-
max_refinement_iterations=v.max_refinement_iterations,
|
|
194
|
-
critic_server_url=v.critic_server_url,
|
|
195
|
-
critic_model_name=v.critic_model_name,
|
|
196
|
-
)
|
|
183
|
+
def _seed_default_llm_profile(llm: LLM, cipher: Cipher | None) -> str:
|
|
184
|
+
"""Mirror the live LLM config into a ``SEED_PROFILE_NAME`` LLM profile.
|
|
197
185
|
|
|
186
|
+
``build_seed_profile`` falls back to the literal name ``SEED_PROFILE_NAME``
|
|
187
|
+
when no LLM profile is active, on the assumption that a profile by that
|
|
188
|
+
name exists — but nothing ever created one, so the seeded agent profile's
|
|
189
|
+
``llm_profile_ref`` dangled from birth (#3933). Mirrors the cloud
|
|
190
|
+
``SaasSettingsStore``'s legacy-LLM backfill: materialize the current
|
|
191
|
+
``agent_settings.llm`` under that name so the reference resolves, unless a
|
|
192
|
+
profile is already stored there (never clobber it).
|
|
198
193
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
stable representation of the user's current configuration (the active
|
|
206
|
-
pointer is otherwise just a lightweight id). ``mcp_server_refs=None`` exposes
|
|
207
|
-
all of the user's MCP servers. An OpenHands profile references the active LLM
|
|
208
|
-
profile (falling back to ``"default"`` when none is set — a soft ref the
|
|
209
|
-
resolver checks at materialize time).
|
|
194
|
+
Existence is checked via ``load()``, not ``list()``: the store resolves a
|
|
195
|
+
name straight to a filesystem path, so on a case-insensitive filesystem
|
|
196
|
+
(macOS/Windows) a differently-cased ``Default`` profile already occupies
|
|
197
|
+
the ``default`` path even though it wouldn't case-sensitively match a
|
|
198
|
+
``list()`` membership check — that mismatch would otherwise let ``save()``
|
|
199
|
+
silently clobber it.
|
|
210
200
|
"""
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
#
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
201
|
+
llm_store = get_llm_profile_store()
|
|
202
|
+
with _store_errors():
|
|
203
|
+
try:
|
|
204
|
+
llm_store.load(SEED_PROFILE_NAME, cipher=cipher)
|
|
205
|
+
return SEED_PROFILE_NAME
|
|
206
|
+
except FileNotFoundError:
|
|
207
|
+
pass
|
|
208
|
+
except ValueError:
|
|
209
|
+
# Something already occupies the name (e.g. corrupted/unparsable
|
|
210
|
+
# file) — never overwrite it; a broken ref surfaces at
|
|
211
|
+
# materialize/launch time instead.
|
|
212
|
+
logger.warning(
|
|
213
|
+
f"Default LLM profile '{SEED_PROFILE_NAME}' exists but "
|
|
214
|
+
"failed to load; leaving it as-is"
|
|
215
|
+
)
|
|
216
|
+
return SEED_PROFILE_NAME
|
|
217
|
+
try:
|
|
218
|
+
llm_store.save(
|
|
219
|
+
SEED_PROFILE_NAME,
|
|
220
|
+
llm,
|
|
221
|
+
include_secrets=True,
|
|
222
|
+
cipher=cipher,
|
|
223
|
+
max_profiles=MAX_PROFILES,
|
|
224
|
+
)
|
|
225
|
+
logger.info(f"Seeded default LLM profile '{SEED_PROFILE_NAME}'")
|
|
226
|
+
except LLMProfileLimitExceeded:
|
|
227
|
+
# Can't mirror the live LLM as a profile; the agent profile's
|
|
228
|
+
# llm_profile_ref will still dangle, but no worse than before.
|
|
229
|
+
logger.warning(
|
|
230
|
+
"Could not seed default LLM profile "
|
|
231
|
+
f"'{SEED_PROFILE_NAME}': profile limit reached"
|
|
232
|
+
)
|
|
233
|
+
return SEED_PROFILE_NAME
|
|
241
234
|
|
|
242
235
|
|
|
243
236
|
def _seed_default_profile(
|
|
@@ -251,13 +244,25 @@ def _seed_default_profile(
|
|
|
251
244
|
The lock spans empty-check + save + pointer write so concurrent first
|
|
252
245
|
requests seed exactly once and the pointer matches the persisted id.
|
|
253
246
|
"""
|
|
254
|
-
with _store_errors(), store.
|
|
247
|
+
with _store_errors(), store.lock():
|
|
255
248
|
# Double-checked under the lock: a concurrent first request may have
|
|
256
249
|
# already seeded (the outer emptiness check in the list endpoint is
|
|
257
250
|
# unlocked).
|
|
258
251
|
if store.list():
|
|
259
252
|
return
|
|
260
|
-
|
|
253
|
+
active_llm_profile = settings.active_profile
|
|
254
|
+
# Falsy check (not `is None`): mirrors build_seed_profile's own
|
|
255
|
+
# `active_llm_profile or SEED_PROFILE_NAME` fallback. A stray empty
|
|
256
|
+
# string (e.g. a hand-edited/legacy settings.json, or a direct
|
|
257
|
+
# PersistedSettings(active_profile="") construction — the HTTP PATCH
|
|
258
|
+
# payload's pattern validator blocks "" but the stored field has no
|
|
259
|
+
# such constraint) is falsy there too, so the backfill must trigger
|
|
260
|
+
# on the same condition or the exact #3933 dangling ref reappears.
|
|
261
|
+
if not active_llm_profile and settings.agent_settings.agent_kind != "acp":
|
|
262
|
+
active_llm_profile = _seed_default_llm_profile(
|
|
263
|
+
settings.agent_settings.llm, cipher
|
|
264
|
+
)
|
|
265
|
+
profile = build_seed_profile(settings.agent_settings, active_llm_profile)
|
|
261
266
|
# Settings persist skills[].mcp_tools encrypted (and never decrypt on
|
|
262
267
|
# load), so decrypt before re-encrypting at save to avoid double-encrypt.
|
|
263
268
|
profile = _decrypt_profile_mcp_tools(profile, cipher)
|
|
@@ -284,29 +289,6 @@ def _summary_id_for_name(store: AgentProfileStore, name: str) -> str | None:
|
|
|
284
289
|
return None
|
|
285
290
|
|
|
286
291
|
|
|
287
|
-
def _existing_identity(
|
|
288
|
-
store: AgentProfileStore, name: str
|
|
289
|
-
) -> tuple[UUID | None, int | None]:
|
|
290
|
-
"""Return the stable ``(id, revision)`` of the profile under ``name``.
|
|
291
|
-
|
|
292
|
-
Used to keep ``id`` stable across an overwrite — the active pointer is keyed
|
|
293
|
-
on it — and to bump ``revision`` monotonically. Ignores a malformed stored
|
|
294
|
-
id (treated as no prior identity).
|
|
295
|
-
"""
|
|
296
|
-
with _store_errors():
|
|
297
|
-
for summary in store.list_summaries():
|
|
298
|
-
if summary.get("name") != name:
|
|
299
|
-
continue
|
|
300
|
-
sid = summary.get("id")
|
|
301
|
-
rev = summary.get("revision")
|
|
302
|
-
try:
|
|
303
|
-
parsed = UUID(str(sid)) if sid is not None else None
|
|
304
|
-
except (ValueError, TypeError):
|
|
305
|
-
parsed = None
|
|
306
|
-
return parsed, rev if isinstance(rev, int) else None
|
|
307
|
-
return None, None
|
|
308
|
-
|
|
309
|
-
|
|
310
292
|
@agent_profiles_router.get("", response_model=AgentProfileListResponse)
|
|
311
293
|
async def list_agent_profiles(request: Request) -> AgentProfileListResponse:
|
|
312
294
|
"""List all stored agent profiles and the active pointer.
|
|
@@ -393,7 +375,7 @@ async def save_agent_profile(
|
|
|
393
375
|
# MCPConfig error embeds the input (which may carry secrets) in ``msg``.
|
|
394
376
|
raise HTTPException(
|
|
395
377
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
396
|
-
detail=
|
|
378
|
+
detail=safe_validation_error_detail(e),
|
|
397
379
|
)
|
|
398
380
|
except Exception:
|
|
399
381
|
# Any other validation failure (e.g. SkillValidationError from a
|
|
@@ -412,19 +394,14 @@ async def save_agent_profile(
|
|
|
412
394
|
store = get_agent_profile_store()
|
|
413
395
|
# The id is server-managed (the active pointer is keyed on it): overwrite
|
|
414
396
|
# keeps the namesake's id and bumps revision; create mints a fresh id,
|
|
415
|
-
# ignoring any client-supplied one.
|
|
416
|
-
#
|
|
417
|
-
#
|
|
397
|
+
# ignoring any client-supplied one. ``save_profile_preserving_identity``
|
|
398
|
+
# holds the store lock across read + mint + save so two concurrent creates
|
|
399
|
+
# of the same new name can't both mint an id and clobber each other.
|
|
418
400
|
try:
|
|
419
|
-
with _store_errors()
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
update={"id": existing_id, "revision": (existing_rev or 0) + 1}
|
|
424
|
-
)
|
|
425
|
-
else:
|
|
426
|
-
profile = profile.model_copy(update={"id": uuid4()})
|
|
427
|
-
store.save(profile, cipher=cipher, max_profiles=MAX_AGENT_PROFILES)
|
|
401
|
+
with _store_errors():
|
|
402
|
+
save_profile_preserving_identity(
|
|
403
|
+
store, profile, cipher=cipher, max_profiles=MAX_AGENT_PROFILES
|
|
404
|
+
)
|
|
428
405
|
except ProfileLimitExceeded:
|
|
429
406
|
raise HTTPException(
|
|
430
407
|
status_code=status.HTTP_409_CONFLICT,
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/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,
|
|
@@ -59,6 +60,7 @@ from openhands.agent_server.server_details_router import (
|
|
|
59
60
|
from openhands.agent_server.settings_router import settings_router
|
|
60
61
|
from openhands.agent_server.skills_router import skills_router
|
|
61
62
|
from openhands.agent_server.sockets import sockets_router
|
|
63
|
+
from openhands.agent_server.sub_agents_router import sub_agents_router
|
|
62
64
|
from openhands.agent_server.tool_preload_service import get_tool_preload_service
|
|
63
65
|
from openhands.agent_server.tool_router import tool_router
|
|
64
66
|
from openhands.agent_server.vscode_router import vscode_router
|
|
@@ -348,6 +350,8 @@ def _add_api_routes(app: FastAPI) -> None:
|
|
|
348
350
|
api_router.include_router(vscode_router)
|
|
349
351
|
api_router.include_router(desktop_router)
|
|
350
352
|
api_router.include_router(skills_router)
|
|
353
|
+
api_router.include_router(sub_agents_router)
|
|
354
|
+
api_router.include_router(plugins_router)
|
|
351
355
|
api_router.include_router(hooks_router)
|
|
352
356
|
api_router.include_router(llm_router)
|
|
353
357
|
api_router.include_router(mcp_router)
|
{openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/config.py
RENAMED
|
@@ -6,6 +6,7 @@ from typing import Any, ClassVar
|
|
|
6
6
|
|
|
7
7
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
|
8
8
|
|
|
9
|
+
from openhands.agent_server.conversation_lease import DEFAULT_LEASE_TTL_SECONDS
|
|
9
10
|
from openhands.agent_server.env_parser import (
|
|
10
11
|
MISSING,
|
|
11
12
|
_get_default_parsers,
|
|
@@ -13,6 +14,7 @@ from openhands.agent_server.env_parser import (
|
|
|
13
14
|
get_env_parser,
|
|
14
15
|
merge,
|
|
15
16
|
)
|
|
17
|
+
from openhands.sdk.marketplace.registration import MarketplaceRegistration
|
|
16
18
|
from openhands.sdk.utils.cipher import Cipher
|
|
17
19
|
|
|
18
20
|
|
|
@@ -236,6 +238,13 @@ class Config(BaseModel):
|
|
|
236
238
|
"The URL where this agent server instance is available externally"
|
|
237
239
|
),
|
|
238
240
|
)
|
|
241
|
+
registered_marketplaces: list[MarketplaceRegistration] = Field(
|
|
242
|
+
default_factory=list,
|
|
243
|
+
description=(
|
|
244
|
+
"Default marketplace registrations for plugin and skill loading. "
|
|
245
|
+
"Can be configured with OH_REGISTERED_MARKETPLACES as a JSON list."
|
|
246
|
+
),
|
|
247
|
+
)
|
|
239
248
|
deferred_init: bool = Field(
|
|
240
249
|
default=False,
|
|
241
250
|
description=(
|
|
@@ -247,6 +256,20 @@ class Config(BaseModel):
|
|
|
247
256
|
"configuration is delivered later."
|
|
248
257
|
),
|
|
249
258
|
)
|
|
259
|
+
lease_ttl_seconds: float = Field(
|
|
260
|
+
default=DEFAULT_LEASE_TTL_SECONDS,
|
|
261
|
+
ge=0.0,
|
|
262
|
+
description=(
|
|
263
|
+
"How long (in seconds) a conversation ownership lease remains valid "
|
|
264
|
+
"without renewal. The lease prevents two server instances from "
|
|
265
|
+
"concurrently owning the same conversation when storage is shared "
|
|
266
|
+
"across instances. Set to 0 to disable leasing entirely, which is "
|
|
267
|
+
"appropriate for single-instance deployments where concurrent "
|
|
268
|
+
"ownership is impossible. Values between 0 and "
|
|
269
|
+
"LEASE_RENEW_INTERVAL_SECONDS (15 s) are valid but cause the lease "
|
|
270
|
+
"to expire before the first renewal, effectively making it one-shot."
|
|
271
|
+
),
|
|
272
|
+
)
|
|
250
273
|
model_config: ClassVar[ConfigDict] = {"frozen": True}
|
|
251
274
|
|
|
252
275
|
@property
|
|
@@ -42,6 +42,12 @@ from openhands.agent_server.models import (
|
|
|
42
42
|
)
|
|
43
43
|
from openhands.sdk import LLM, Agent, TextContent
|
|
44
44
|
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
45
|
+
from openhands.sdk.marketplace.registry import (
|
|
46
|
+
MarketplaceNotFoundError,
|
|
47
|
+
PluginNotFoundError,
|
|
48
|
+
PluginResolutionError,
|
|
49
|
+
)
|
|
50
|
+
from openhands.sdk.plugin import PluginFetchError
|
|
45
51
|
from openhands.sdk.profiles.resolver import DanglingMcpServerRef, ProfileNotFound
|
|
46
52
|
from openhands.sdk.tool.client_tool import ClientToolRegistrationError
|
|
47
53
|
from openhands.sdk.workspace import LocalWorkspace
|
|
@@ -497,6 +503,42 @@ async def switch_conversation_llm(
|
|
|
497
503
|
return Success()
|
|
498
504
|
|
|
499
505
|
|
|
506
|
+
@conversation_router.post(
|
|
507
|
+
"/{conversation_id}/load_plugin",
|
|
508
|
+
responses={
|
|
509
|
+
400: {"description": "Invalid plugin reference or inactive conversation"},
|
|
510
|
+
404: {"description": "Conversation or plugin not found"},
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
async def load_conversation_plugin(
|
|
514
|
+
conversation_id: UUID,
|
|
515
|
+
plugin_ref: str = Body(..., embed=True),
|
|
516
|
+
conversation_service: ConversationService = Depends(get_conversation_service),
|
|
517
|
+
) -> Success:
|
|
518
|
+
"""Load a plugin from the conversation's registered marketplaces."""
|
|
519
|
+
event_service = await conversation_service.get_event_service(conversation_id)
|
|
520
|
+
if event_service is None:
|
|
521
|
+
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
|
522
|
+
try:
|
|
523
|
+
await event_service.load_plugin(plugin_ref)
|
|
524
|
+
except (PluginNotFoundError, MarketplaceNotFoundError) as e:
|
|
525
|
+
raise HTTPException(
|
|
526
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
527
|
+
detail=str(e),
|
|
528
|
+
)
|
|
529
|
+
except (
|
|
530
|
+
PluginResolutionError,
|
|
531
|
+
PluginFetchError,
|
|
532
|
+
FileNotFoundError,
|
|
533
|
+
ValueError,
|
|
534
|
+
) as e:
|
|
535
|
+
raise HTTPException(
|
|
536
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
537
|
+
detail=str(e),
|
|
538
|
+
)
|
|
539
|
+
return Success()
|
|
540
|
+
|
|
541
|
+
|
|
500
542
|
@conversation_router.post(
|
|
501
543
|
"/{conversation_id}/switch_acp_model",
|
|
502
544
|
responses={
|
|
@@ -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
|
|
@@ -12,7 +13,10 @@ import httpx
|
|
|
12
13
|
from pydantic import BaseModel
|
|
13
14
|
|
|
14
15
|
from openhands.agent_server.config import Config, WebhookSpec
|
|
15
|
-
from openhands.agent_server.conversation_lease import
|
|
16
|
+
from openhands.agent_server.conversation_lease import (
|
|
17
|
+
DEFAULT_LEASE_TTL_SECONDS,
|
|
18
|
+
ConversationLeaseHeldError,
|
|
19
|
+
)
|
|
16
20
|
from openhands.agent_server.event_service import (
|
|
17
21
|
LEASE_RENEW_INTERVAL_SECONDS,
|
|
18
22
|
EventService,
|
|
@@ -441,6 +445,7 @@ class ConversationService:
|
|
|
441
445
|
cipher: Cipher | None = None
|
|
442
446
|
owner_instance_id: str = field(default_factory=lambda: uuid4().hex)
|
|
443
447
|
max_concurrent_runs: int = 10
|
|
448
|
+
lease_ttl_seconds: float = DEFAULT_LEASE_TTL_SECONDS
|
|
444
449
|
_event_services: dict[UUID, EventService] | None = field(default=None, init=False)
|
|
445
450
|
_conversation_webhook_subscribers: list["ConversationWebhookSubscriber"] = field(
|
|
446
451
|
default_factory=list, init=False
|
|
@@ -1212,6 +1217,7 @@ class ConversationService:
|
|
|
1212
1217
|
),
|
|
1213
1218
|
cipher=config.cipher,
|
|
1214
1219
|
max_concurrent_runs=config.max_concurrent_runs,
|
|
1220
|
+
lease_ttl_seconds=config.lease_ttl_seconds,
|
|
1215
1221
|
)
|
|
1216
1222
|
|
|
1217
1223
|
async def _start_event_service(self, stored: StoredConversation) -> EventService:
|
|
@@ -1224,6 +1230,7 @@ class ConversationService:
|
|
|
1224
1230
|
conversations_dir=self.conversations_dir,
|
|
1225
1231
|
cipher=self.cipher,
|
|
1226
1232
|
owner_instance_id=self.owner_instance_id,
|
|
1233
|
+
lease_ttl_seconds=self.lease_ttl_seconds,
|
|
1227
1234
|
)
|
|
1228
1235
|
# Lease renewal is handled by the centralized
|
|
1229
1236
|
# _renew_all_leases_loop task on ConversationService.
|
|
@@ -1367,6 +1374,12 @@ class WebhookSubscriber(Subscriber):
|
|
|
1367
1374
|
session_api_key: str | None = None
|
|
1368
1375
|
queue: list[Event] = field(default_factory=list)
|
|
1369
1376
|
_flush_timer: asyncio.Task | None = field(default=None, init=False)
|
|
1377
|
+
# Per-instance sleep seam so tests override delays without patching the
|
|
1378
|
+
# global asyncio.sleep. default_factory (not default) keeps it an instance
|
|
1379
|
+
# attribute, else the function would be descriptor-bound as a method.
|
|
1380
|
+
_sleep: Callable[[float], Awaitable[None]] = field(
|
|
1381
|
+
default_factory=lambda: asyncio.sleep, init=False
|
|
1382
|
+
)
|
|
1370
1383
|
|
|
1371
1384
|
async def __call__(self, event: Event):
|
|
1372
1385
|
"""Add event to queue and post to webhook when buffer size is reached."""
|
|
@@ -1437,7 +1450,7 @@ class WebhookSubscriber(Subscriber):
|
|
|
1437
1450
|
except Exception as e:
|
|
1438
1451
|
logger.warning(f"Webhook post attempt {attempt + 1} failed: {e}")
|
|
1439
1452
|
if attempt < self.spec.num_retries:
|
|
1440
|
-
await
|
|
1453
|
+
await self._sleep(self.spec.retry_delay)
|
|
1441
1454
|
else:
|
|
1442
1455
|
logger.error(
|
|
1443
1456
|
f"Failed to post events to webhook {events_url} "
|
|
@@ -1462,7 +1475,7 @@ class WebhookSubscriber(Subscriber):
|
|
|
1462
1475
|
async def _flush_after_delay(self):
|
|
1463
1476
|
"""Wait for flush_delay seconds then flush events if any exist."""
|
|
1464
1477
|
try:
|
|
1465
|
-
await
|
|
1478
|
+
await self._sleep(self.spec.flush_delay)
|
|
1466
1479
|
# Only flush if there are events in the queue
|
|
1467
1480
|
if self.queue:
|
|
1468
1481
|
await self._post_events()
|
|
@@ -1479,6 +1492,10 @@ class ConversationWebhookSubscriber:
|
|
|
1479
1492
|
|
|
1480
1493
|
spec: WebhookSpec
|
|
1481
1494
|
session_api_key: str | None = None
|
|
1495
|
+
# Per-instance sleep seam; see WebhookSubscriber._sleep.
|
|
1496
|
+
_sleep: Callable[[float], Awaitable[None]] = field(
|
|
1497
|
+
default_factory=lambda: asyncio.sleep, init=False
|
|
1498
|
+
)
|
|
1482
1499
|
|
|
1483
1500
|
async def post_conversation_info(self, conversation_info: BaseModel):
|
|
1484
1501
|
"""Post conversation info to the webhook immediately (no batching)."""
|
|
@@ -1516,7 +1533,7 @@ class ConversationWebhookSubscriber:
|
|
|
1516
1533
|
f"Conversation webhook post attempt {attempt + 1} failed: {e}"
|
|
1517
1534
|
)
|
|
1518
1535
|
if attempt < self.spec.num_retries:
|
|
1519
|
-
await
|
|
1536
|
+
await self._sleep(self.spec.retry_delay)
|
|
1520
1537
|
else:
|
|
1521
1538
|
# Log response content for debugging failures
|
|
1522
1539
|
response_content = (
|
|
@@ -9,6 +9,7 @@ from uuid import UUID, uuid4
|
|
|
9
9
|
from pydantic import ValidationError
|
|
10
10
|
|
|
11
11
|
from openhands.agent_server.conversation_lease import (
|
|
12
|
+
DEFAULT_LEASE_TTL_SECONDS,
|
|
12
13
|
ConversationLease,
|
|
13
14
|
ConversationOwnershipLostError,
|
|
14
15
|
)
|
|
@@ -81,6 +82,7 @@ class EventService:
|
|
|
81
82
|
conversations_dir: Path
|
|
82
83
|
cipher: Cipher | None = None
|
|
83
84
|
owner_instance_id: str = field(default_factory=lambda: uuid4().hex)
|
|
85
|
+
lease_ttl_seconds: float = DEFAULT_LEASE_TTL_SECONDS
|
|
84
86
|
_conversation: LocalConversation | None = field(default=None, init=False)
|
|
85
87
|
_pub_sub: PubSub[Event] = field(
|
|
86
88
|
default_factory=lambda: PubSub[Event](max_subscribers=50), init=False
|
|
@@ -605,10 +607,9 @@ class EventService:
|
|
|
605
607
|
from callbacks that may run in different threads. Events are emitted through
|
|
606
608
|
the conversation's normal event flow to ensure they are persisted.
|
|
607
609
|
"""
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
610
|
+
main_loop = self._main_loop
|
|
611
|
+
conversation = self._conversation
|
|
612
|
+
if main_loop and main_loop.is_running() and conversation:
|
|
612
613
|
# Wrap _on_event with lock acquisition to ensure thread-safe access
|
|
613
614
|
# to conversation state and event log during concurrent operations
|
|
614
615
|
def locked_on_event():
|
|
@@ -617,7 +618,7 @@ class EventService:
|
|
|
617
618
|
|
|
618
619
|
# Run the locked callback in an executor to ensure the event is
|
|
619
620
|
# both persisted and sent to WebSocket subscribers
|
|
620
|
-
|
|
621
|
+
main_loop.run_in_executor(None, locked_on_event)
|
|
621
622
|
|
|
622
623
|
def _setup_llm_log_streaming(self, agent: AgentBase) -> None:
|
|
623
624
|
"""Configure LLM log callbacks to stream logs via events."""
|
|
@@ -633,13 +634,16 @@ class EventService:
|
|
|
633
634
|
filename: str, log_data: str, uid=usage_id, model=model_name
|
|
634
635
|
) -> None:
|
|
635
636
|
"""Callback to emit LLM completion logs as events."""
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
637
|
+
try:
|
|
638
|
+
event = LLMCompletionLogEvent(
|
|
639
|
+
filename=filename,
|
|
640
|
+
log_data=log_data,
|
|
641
|
+
model_name=model,
|
|
642
|
+
usage_id=uid,
|
|
643
|
+
)
|
|
644
|
+
self._emit_event_from_thread(event)
|
|
645
|
+
except Exception:
|
|
646
|
+
logger.exception("Failed to emit LLM completion log event")
|
|
643
647
|
|
|
644
648
|
llm.telemetry.set_log_completions_callback(log_callback)
|
|
645
649
|
|
|
@@ -731,12 +735,16 @@ class EventService:
|
|
|
731
735
|
|
|
732
736
|
# self.stored contains an Agent configuration we can instantiate
|
|
733
737
|
self.conversation_dir.mkdir(parents=True, exist_ok=True)
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
738
|
+
# lease_ttl_seconds=0 disables leasing for single-instance deployments
|
|
739
|
+
# where shared-storage stale leases would otherwise block pod restarts.
|
|
740
|
+
if self.lease_ttl_seconds > 0:
|
|
741
|
+
self._lease = ConversationLease(
|
|
742
|
+
conversation_dir=self.conversation_dir,
|
|
743
|
+
owner_instance_id=self.owner_instance_id,
|
|
744
|
+
ttl_seconds=self.lease_ttl_seconds,
|
|
745
|
+
)
|
|
746
|
+
lease_claim = self._lease.claim()
|
|
747
|
+
self._lease_generation = lease_claim.generation
|
|
740
748
|
workspace = self.stored.workspace
|
|
741
749
|
assert isinstance(workspace, LocalWorkspace)
|
|
742
750
|
working_dir = Path(workspace.working_dir)
|
|
@@ -823,6 +831,7 @@ class EventService:
|
|
|
823
831
|
user_id=self.stored.user_id,
|
|
824
832
|
observability_metadata=self.stored.observability_metadata,
|
|
825
833
|
observability_tags=self.stored.observability_tags,
|
|
834
|
+
observability_span_name=self.stored.observability_span_name,
|
|
826
835
|
)
|
|
827
836
|
|
|
828
837
|
conversation.set_confirmation_policy(self.stored.confirmation_policy)
|
|
@@ -1360,6 +1369,13 @@ class EventService:
|
|
|
1360
1369
|
None, self._conversation.set_security_analyzer, security_analyzer
|
|
1361
1370
|
)
|
|
1362
1371
|
|
|
1372
|
+
async def load_plugin(self, plugin_ref: str) -> None:
|
|
1373
|
+
"""Load a marketplace plugin into the active conversation."""
|
|
1374
|
+
if self._conversation is None:
|
|
1375
|
+
raise ValueError("inactive_service")
|
|
1376
|
+
loop = asyncio.get_running_loop()
|
|
1377
|
+
await loop.run_in_executor(None, self._conversation.load_plugin, plugin_ref)
|
|
1378
|
+
|
|
1363
1379
|
async def switch_acp_model(self, model: str) -> None:
|
|
1364
1380
|
"""Switch the model on an ACP conversation.
|
|
1365
1381
|
|