openhands-agent-server 1.29.3__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.
Files changed (69) hide show
  1. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/agent_profiles_router.py +79 -102
  3. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/api.py +2 -0
  4. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/config.py +23 -0
  5. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_router.py +42 -0
  6. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_service.py +7 -1
  7. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/event_service.py +20 -6
  8. openhands_agent_server-1.30.0/openhands/agent_server/file_router.py +989 -0
  9. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/router.py +59 -0
  10. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/service.py +3 -0
  11. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/skills_router.py +33 -2
  12. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/skills_service.py +67 -5
  13. openhands_agent_server-1.30.0/openhands/agent_server/sub_agents_router.py +130 -0
  14. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  15. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/SOURCES.txt +2 -0
  16. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/pyproject.toml +1 -1
  17. openhands_agent_server-1.29.3/openhands/agent_server/file_router.py +0 -333
  18. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/__init__.py +0 -0
  19. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/__main__.py +0 -0
  20. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  21. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/auth_router.py +0 -0
  22. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/bash_router.py +0 -0
  23. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/bash_service.py +0 -0
  24. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_lease.py +0 -0
  25. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/dependencies.py +0 -0
  26. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/desktop_router.py +0 -0
  27. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/desktop_service.py +0 -0
  28. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/Dockerfile +0 -0
  29. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/build.py +0 -0
  30. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  31. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/env_parser.py +0 -0
  32. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/event_router.py +0 -0
  33. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/git_router.py +0 -0
  34. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/hooks_router.py +0 -0
  35. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/hooks_service.py +0 -0
  36. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/init_router.py +0 -0
  37. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/llm_router.py +0 -0
  38. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/logging_config.py +0 -0
  39. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/mcp_router.py +0 -0
  40. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/middleware.py +0 -0
  41. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/models.py +0 -0
  42. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/__init__.py +0 -0
  43. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/models.py +0 -0
  44. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/openapi.py +0 -0
  45. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/__init__.py +0 -0
  46. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/models.py +0 -0
  47. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/store.py +0 -0
  48. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/plugins_router.py +0 -0
  49. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/plugins_service.py +0 -0
  50. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/profiles_router.py +0 -0
  51. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/pub_sub.py +0 -0
  52. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/py.typed +0 -0
  53. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/server_details_router.py +0 -0
  54. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/settings_router.py +0 -0
  55. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/sockets.py +0 -0
  56. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/tool_preload_service.py +0 -0
  57. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/tool_router.py +0 -0
  58. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/utils.py +0 -0
  59. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  60. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  61. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_router.py +0 -0
  62. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_service.py +0 -0
  63. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/workspace_router.py +0 -0
  64. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands/agent_server/workspaces_router.py +0 -0
  65. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  66. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  67. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  68. {openhands_agent_server-1.29.3 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  69. {openhands_agent_server-1.29.3 → 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.29.3
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
- ProfileVerificationSettings,
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 _profile_verification(v: VerificationSettings) -> ProfileVerificationSettings:
183
- """Project the secret-free subset of ``VerificationSettings``.
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
- def _build_seed_profile(
200
- agent_settings: AgentSettingsConfig, active_llm_profile: str | None
201
- ) -> OpenHandsAgentProfile | ACPAgentProfile:
202
- """Build one ``AgentProfile`` faithfully from the current ``agent_settings``.
203
-
204
- Carries every cleanly-overlapping launch field so the migrated profile is a
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
- if agent_settings.agent_kind == "acp":
212
- return ACPAgentProfile(
213
- name=SEED_PROFILE_NAME,
214
- acp_server=agent_settings.acp_server,
215
- acp_model=agent_settings.acp_model,
216
- acp_session_mode=agent_settings.acp_session_mode,
217
- acp_prompt_timeout=agent_settings.acp_prompt_timeout,
218
- # settings store the command as a token list; the profile holds a
219
- # single (re-parseable) string. Empty list => use the server default.
220
- acp_command=(
221
- shlex.join(agent_settings.acp_command)
222
- if agent_settings.acp_command
223
- else None
224
- ),
225
- acp_args=list(agent_settings.acp_args) or None,
226
- mcp_server_refs=None,
227
- )
228
- context = agent_settings.agent_context
229
- return OpenHandsAgentProfile(
230
- name=SEED_PROFILE_NAME,
231
- llm_profile_ref=active_llm_profile or SEED_PROFILE_NAME,
232
- agent=agent_settings.agent,
233
- skills=list(context.skills),
234
- system_message_suffix=context.system_message_suffix,
235
- condenser=agent_settings.condenser,
236
- verification=_profile_verification(agent_settings.verification),
237
- enable_sub_agents=agent_settings.enable_sub_agents,
238
- tool_concurrency_limit=agent_settings.tool_concurrency_limit,
239
- mcp_server_refs=None,
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._acquire_lock():
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
- profile = _build_seed_profile(settings.agent_settings, settings.active_profile)
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=[{"loc": err["loc"], "type": err["type"]} for err in e.errors()],
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. The lock spans read + mint + save so two
416
- # concurrent creates of the same new name can't both mint an id and clobber
417
- # each other (the seed path guards the same window).
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(), store._acquire_lock():
420
- existing_id, existing_rev = _existing_identity(store, name)
421
- if existing_id is not None:
422
- profile = profile.model_copy(
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,
@@ -60,6 +60,7 @@ from openhands.agent_server.server_details_router import (
60
60
  from openhands.agent_server.settings_router import settings_router
61
61
  from openhands.agent_server.skills_router import skills_router
62
62
  from openhands.agent_server.sockets import sockets_router
63
+ from openhands.agent_server.sub_agents_router import sub_agents_router
63
64
  from openhands.agent_server.tool_preload_service import get_tool_preload_service
64
65
  from openhands.agent_server.tool_router import tool_router
65
66
  from openhands.agent_server.vscode_router import vscode_router
@@ -349,6 +350,7 @@ def _add_api_routes(app: FastAPI) -> None:
349
350
  api_router.include_router(vscode_router)
350
351
  api_router.include_router(desktop_router)
351
352
  api_router.include_router(skills_router)
353
+ api_router.include_router(sub_agents_router)
352
354
  api_router.include_router(plugins_router)
353
355
  api_router.include_router(hooks_router)
354
356
  api_router.include_router(llm_router)
@@ -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={
@@ -13,7 +13,10 @@ import httpx
13
13
  from pydantic import BaseModel
14
14
 
15
15
  from openhands.agent_server.config import Config, WebhookSpec
16
- from openhands.agent_server.conversation_lease import ConversationLeaseHeldError
16
+ from openhands.agent_server.conversation_lease import (
17
+ DEFAULT_LEASE_TTL_SECONDS,
18
+ ConversationLeaseHeldError,
19
+ )
17
20
  from openhands.agent_server.event_service import (
18
21
  LEASE_RENEW_INTERVAL_SECONDS,
19
22
  EventService,
@@ -442,6 +445,7 @@ class ConversationService:
442
445
  cipher: Cipher | None = None
443
446
  owner_instance_id: str = field(default_factory=lambda: uuid4().hex)
444
447
  max_concurrent_runs: int = 10
448
+ lease_ttl_seconds: float = DEFAULT_LEASE_TTL_SECONDS
445
449
  _event_services: dict[UUID, EventService] | None = field(default=None, init=False)
446
450
  _conversation_webhook_subscribers: list["ConversationWebhookSubscriber"] = field(
447
451
  default_factory=list, init=False
@@ -1213,6 +1217,7 @@ class ConversationService:
1213
1217
  ),
1214
1218
  cipher=config.cipher,
1215
1219
  max_concurrent_runs=config.max_concurrent_runs,
1220
+ lease_ttl_seconds=config.lease_ttl_seconds,
1216
1221
  )
1217
1222
 
1218
1223
  async def _start_event_service(self, stored: StoredConversation) -> EventService:
@@ -1225,6 +1230,7 @@ class ConversationService:
1225
1230
  conversations_dir=self.conversations_dir,
1226
1231
  cipher=self.cipher,
1227
1232
  owner_instance_id=self.owner_instance_id,
1233
+ lease_ttl_seconds=self.lease_ttl_seconds,
1228
1234
  )
1229
1235
  # Lease renewal is handled by the centralized
1230
1236
  # _renew_all_leases_loop task on ConversationService.
@@ -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
@@ -733,12 +735,16 @@ class EventService:
733
735
 
734
736
  # self.stored contains an Agent configuration we can instantiate
735
737
  self.conversation_dir.mkdir(parents=True, exist_ok=True)
736
- self._lease = ConversationLease(
737
- conversation_dir=self.conversation_dir,
738
- owner_instance_id=self.owner_instance_id,
739
- )
740
- lease_claim = self._lease.claim()
741
- self._lease_generation = lease_claim.generation
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
742
748
  workspace = self.stored.workspace
743
749
  assert isinstance(workspace, LocalWorkspace)
744
750
  working_dir = Path(workspace.working_dir)
@@ -825,6 +831,7 @@ class EventService:
825
831
  user_id=self.stored.user_id,
826
832
  observability_metadata=self.stored.observability_metadata,
827
833
  observability_tags=self.stored.observability_tags,
834
+ observability_span_name=self.stored.observability_span_name,
828
835
  )
829
836
 
830
837
  conversation.set_confirmation_policy(self.stored.confirmation_policy)
@@ -1362,6 +1369,13 @@ class EventService:
1362
1369
  None, self._conversation.set_security_analyzer, security_analyzer
1363
1370
  )
1364
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
+
1365
1379
  async def switch_acp_model(self, model: str) -> None:
1366
1380
  """Switch the model on an ACP conversation.
1367
1381