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.
Files changed (69) hide show
  1. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/agent_profiles_router.py +79 -102
  3. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/api.py +4 -0
  4. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/config.py +23 -0
  5. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_router.py +42 -0
  6. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_service.py +21 -4
  7. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/event_service.py +34 -18
  8. openhands_agent_server-1.30.0/openhands/agent_server/file_router.py +989 -0
  9. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/router.py +59 -0
  10. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/service.py +3 -0
  11. openhands_agent_server-1.30.0/openhands/agent_server/plugins_router.py +333 -0
  12. openhands_agent_server-1.30.0/openhands/agent_server/plugins_service.py +295 -0
  13. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/skills_router.py +33 -2
  14. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/skills_service.py +67 -5
  15. openhands_agent_server-1.30.0/openhands/agent_server/sub_agents_router.py +130 -0
  16. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  17. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/SOURCES.txt +6 -0
  18. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/pyproject.toml +1 -1
  19. openhands_agent_server-1.29.2/openhands/agent_server/file_router.py +0 -333
  20. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/__init__.py +0 -0
  21. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/__main__.py +0 -0
  22. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  23. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/auth_router.py +0 -0
  24. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/bash_router.py +0 -0
  25. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/bash_service.py +0 -0
  26. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/conversation_lease.py +0 -0
  27. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/dependencies.py +0 -0
  28. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/desktop_router.py +0 -0
  29. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/desktop_service.py +0 -0
  30. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/Dockerfile +0 -0
  31. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/build.py +0 -0
  32. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/docker/wallpaper.svg +0 -0
  33. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/env_parser.py +0 -0
  34. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/event_router.py +0 -0
  35. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/git_router.py +0 -0
  36. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/hooks_router.py +0 -0
  37. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/hooks_service.py +0 -0
  38. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/init_router.py +0 -0
  39. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/llm_router.py +0 -0
  40. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/logging_config.py +0 -0
  41. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/mcp_router.py +0 -0
  42. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/middleware.py +0 -0
  43. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/models.py +0 -0
  44. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/__init__.py +0 -0
  45. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openai/models.py +0 -0
  46. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/openapi.py +0 -0
  47. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/__init__.py +0 -0
  48. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/models.py +0 -0
  49. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/persistence/store.py +0 -0
  50. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/profiles_router.py +0 -0
  51. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/pub_sub.py +0 -0
  52. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/py.typed +0 -0
  53. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/server_details_router.py +0 -0
  54. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/settings_router.py +0 -0
  55. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/sockets.py +0 -0
  56. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/tool_preload_service.py +0 -0
  57. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/tool_router.py +0 -0
  58. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/utils.py +0 -0
  59. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  60. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  61. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_router.py +0 -0
  62. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/vscode_service.py +0 -0
  63. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/workspace_router.py +0 -0
  64. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands/agent_server/workspaces_router.py +0 -0
  65. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  66. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  67. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  68. {openhands_agent_server-1.29.2 → openhands_agent_server-1.30.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  69. {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.29.2
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,
@@ -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)
@@ -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 ConversationLeaseHeldError
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 asyncio.sleep(self.spec.retry_delay)
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 asyncio.sleep(self.spec.flush_delay)
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 asyncio.sleep(self.spec.retry_delay)
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
- if self._main_loop and self._main_loop.is_running() and self._conversation:
609
- # Capture conversation reference for closure
610
- conversation = self._conversation
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
- self._main_loop.run_in_executor(None, locked_on_event)
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
- 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)
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
- self._lease = ConversationLease(
735
- conversation_dir=self.conversation_dir,
736
- owner_instance_id=self.owner_instance_id,
737
- )
738
- lease_claim = self._lease.claim()
739
- 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
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