openhands-agent-server 1.29.0__tar.gz → 1.29.2__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 (65) hide show
  1. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/agent_profiles_router.py +10 -9
  3. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/api.py +22 -27
  4. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/bash_router.py +20 -6
  5. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_service.py +10 -6
  6. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/dependencies.py +35 -32
  7. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/Dockerfile +13 -4
  8. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/event_service.py +24 -3
  9. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/file_router.py +30 -16
  10. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/init_router.py +17 -0
  11. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/router.py +17 -18
  12. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/service.py +7 -4
  13. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/__init__.py +4 -0
  14. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/store.py +61 -0
  15. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/profiles_router.py +10 -10
  16. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/sockets.py +44 -6
  17. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  18. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/pyproject.toml +1 -1
  19. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/__init__.py +0 -0
  20. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/__main__.py +0 -0
  21. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/_secrets_exposure.py +0 -0
  22. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/auth_router.py +0 -0
  23. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/bash_service.py +0 -0
  24. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/config.py +0 -0
  25. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_lease.py +0 -0
  26. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/conversation_router.py +0 -0
  27. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/desktop_router.py +0 -0
  28. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/desktop_service.py +0 -0
  29. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/build.py +0 -0
  30. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/docker/wallpaper.svg +0 -0
  31. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/env_parser.py +0 -0
  32. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/event_router.py +0 -0
  33. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/git_router.py +0 -0
  34. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/hooks_router.py +0 -0
  35. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/hooks_service.py +0 -0
  36. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/llm_router.py +0 -0
  37. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/logging_config.py +0 -0
  38. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/mcp_router.py +0 -0
  39. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/middleware.py +0 -0
  40. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/models.py +0 -0
  41. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/__init__.py +0 -0
  42. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openai/models.py +0 -0
  43. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/openapi.py +0 -0
  44. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/persistence/models.py +0 -0
  45. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/pub_sub.py +0 -0
  46. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/py.typed +0 -0
  47. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/server_details_router.py +0 -0
  48. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/settings_router.py +0 -0
  49. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/skills_router.py +0 -0
  50. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/skills_service.py +0 -0
  51. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/tool_preload_service.py +0 -0
  52. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/tool_router.py +0 -0
  53. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/utils.py +0 -0
  54. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  55. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  56. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_router.py +0 -0
  57. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/vscode_service.py +0 -0
  58. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/workspace_router.py +0 -0
  59. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands/agent_server/workspaces_router.py +0 -0
  60. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/SOURCES.txt +0 -0
  61. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  62. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  63. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/requires.txt +0 -0
  64. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/openhands_agent_server.egg-info/top_level.txt +0 -0
  65. {openhands_agent_server-1.29.0 → openhands_agent_server-1.29.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.29.0
3
+ Version: 1.29.2
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
@@ -30,9 +30,10 @@ from openhands.agent_server._secrets_exposure import (
30
30
  )
31
31
  from openhands.agent_server.persistence import (
32
32
  PersistedSettings,
33
+ get_agent_profile_store,
34
+ get_llm_profile_store,
33
35
  get_settings_store,
34
36
  )
35
- from openhands.sdk.llm.llm_profile_store import LLMProfileStore
36
37
  from openhands.sdk.logger import get_logger
37
38
  from openhands.sdk.profiles import (
38
39
  ACPAgentProfile,
@@ -318,7 +319,7 @@ async def list_agent_profiles(request: Request) -> AgentProfileListResponse:
318
319
  settings_store = get_settings_store(config)
319
320
  settings = settings_store.load() or PersistedSettings()
320
321
 
321
- store = AgentProfileStore()
322
+ store = get_agent_profile_store()
322
323
  with _store_errors():
323
324
  existing = store.list()
324
325
 
@@ -347,7 +348,7 @@ async def get_agent_profile(
347
348
  expose_mode = parse_expose_secrets_header(request)
348
349
  cipher = get_cipher(request)
349
350
 
350
- store = AgentProfileStore()
351
+ store = get_agent_profile_store()
351
352
  try:
352
353
  with _store_errors():
353
354
  profile = store.load(name, cipher=cipher)
@@ -408,7 +409,7 @@ async def save_agent_profile(
408
409
  # secret once rather than double-encrypting the token.
409
410
  profile = _decrypt_profile_mcp_tools(profile, cipher)
410
411
 
411
- store = AgentProfileStore()
412
+ store = get_agent_profile_store()
412
413
  # The id is server-managed (the active pointer is keyed on it): overwrite
413
414
  # keeps the namesake's id and bumps revision; create mints a fresh id,
414
415
  # ignoring any client-supplied one. The lock spans read + mint + save so two
@@ -448,7 +449,7 @@ async def delete_agent_profile(
448
449
  If the deleted profile was the active one, ``active_agent_profile_id`` is
449
450
  cleared.
450
451
  """
451
- store = AgentProfileStore()
452
+ store = get_agent_profile_store()
452
453
  deleted_id = _summary_id_for_name(store, name)
453
454
 
454
455
  with _store_errors():
@@ -485,7 +486,7 @@ async def rename_agent_profile(
485
486
  survives the rename untouched. Returns 404 if the source is missing, 409 if
486
487
  ``new_name`` is taken.
487
488
  """
488
- store = AgentProfileStore()
489
+ store = get_agent_profile_store()
489
490
  try:
490
491
  with _store_errors():
491
492
  store.rename(name, body.new_name)
@@ -520,7 +521,7 @@ async def activate_agent_profile(
520
521
  ``/activate``, this does **not** write ``agent_settings`` (the
521
522
  creation-time-only contract). Returns 404 if no stored profile has that id.
522
523
  """
523
- store = AgentProfileStore()
524
+ store = get_agent_profile_store()
524
525
  with _store_errors():
525
526
  known_ids = {
526
527
  str(s["id"]) for s in store.list_summaries() if s.get("id") is not None
@@ -574,7 +575,7 @@ async def materialize_agent_profile(
574
575
  """
575
576
  cipher = get_cipher(request)
576
577
 
577
- store = AgentProfileStore()
578
+ store = get_agent_profile_store()
578
579
  try:
579
580
  with _store_errors():
580
581
  profile = store.load(name, cipher=cipher)
@@ -592,7 +593,7 @@ async def materialize_agent_profile(
592
593
  settings = get_settings_store(config).load() or PersistedSettings()
593
594
  mcp_config = settings.agent_settings.mcp_config
594
595
 
595
- llm_store = LLMProfileStore()
596
+ llm_store = get_llm_profile_store()
596
597
  return resolve_agent_profile_dry_run(
597
598
  profile,
598
599
  llm_store=llm_store,
@@ -29,8 +29,8 @@ from openhands.agent_server.conversation_service import (
29
29
  get_default_conversation_service,
30
30
  )
31
31
  from openhands.agent_server.dependencies import (
32
- create_session_api_key_dependency,
33
- create_workspace_session_dependency,
32
+ check_session_api_key,
33
+ check_workspace_session,
34
34
  )
35
35
  from openhands.agent_server.desktop_router import desktop_router
36
36
  from openhands.agent_server.desktop_service import get_desktop_service
@@ -47,7 +47,7 @@ from openhands.agent_server.llm_router import llm_router
47
47
  from openhands.agent_server.mcp_router import mcp_router
48
48
  from openhands.agent_server.middleware import CORSDispatcher
49
49
  from openhands.agent_server.openai.router import (
50
- create_openai_api_key_dependency,
50
+ check_openai_api_key,
51
51
  openai_router,
52
52
  )
53
53
  from openhands.agent_server.profiles_router import profiles_router
@@ -234,6 +234,9 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
234
234
  mark_initialization_complete()
235
235
  logger.info("Server initialization complete - ready to serve requests")
236
236
 
237
+ bash_svc = get_default_bash_event_service()
238
+ api.state.bash_event_service = bash_svc
239
+
237
240
  async with service:
238
241
  api.state.conversation_service = service
239
242
 
@@ -241,7 +244,7 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
241
244
  retention_task: asyncio.Task | None = None
242
245
  if config.bash_events_retention_seconds is not None:
243
246
  retention_task = asyncio.create_task(
244
- get_default_bash_event_service().run_retention_cleanup_loop(
247
+ bash_svc.run_retention_cleanup_loop(
245
248
  config.bash_events_retention_seconds
246
249
  )
247
250
  )
@@ -310,12 +313,8 @@ def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None:
310
313
  return None
311
314
 
312
315
 
313
- def _add_api_routes(app: FastAPI, config: Config) -> None:
314
- """Add all API routes to the FastAPI application.
315
-
316
- Args:
317
- app: FastAPI application instance to add routes to.
318
- """
316
+ def _add_api_routes(app: FastAPI) -> None:
317
+ """Add all API routes to the FastAPI application."""
319
318
  app.include_router(server_details_router)
320
319
 
321
320
  # The /api/init endpoint bypasses both the session-key auth and the
@@ -330,12 +329,14 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
330
329
  # Header-only auth: applied to every /api/* route EXCEPT the workspace
331
330
  # static-file routes (handled separately below). Cookies are NOT honored
332
331
  # here so that we don't expand the CSRF surface across the whole API.
333
- dependencies = []
334
- if config.session_api_keys:
335
- dependencies.append(Depends(create_session_api_key_dependency(config)))
336
- # Dormant gate: when ``deferred_init`` is True this 503s every /api/*
337
- # route until POST /api/init completes. No-op for non-deferred deployments.
338
- dependencies.append(Depends(require_initialized))
332
+ # check_session_api_key reads config from request.app.state at request time,
333
+ # so keys delivered via POST /api/init are honoured without re-registering routes.
334
+ dependencies = [
335
+ Depends(check_session_api_key),
336
+ # Dormant gate: 503s every /api/* route until POST /api/init completes.
337
+ # No-op for non-deferred deployments.
338
+ Depends(require_initialized),
339
+ ]
339
340
 
340
341
  api_router = APIRouter(prefix="/api", dependencies=dependencies)
341
342
  api_router.include_router(event_router)
@@ -359,22 +360,16 @@ def _add_api_routes(app: FastAPI, config: Config) -> None:
359
360
  api_router.include_router(auth_router)
360
361
  app.include_router(api_router)
361
362
 
362
- openai_dependencies = []
363
- if config.session_api_keys:
364
- openai_dependencies.append(Depends(create_openai_api_key_dependency(config)))
365
- app.include_router(openai_router, dependencies=openai_dependencies)
363
+ app.include_router(openai_router, dependencies=[Depends(check_openai_api_key)])
366
364
 
367
365
  # Workspace static-file routes get their own auth group that accepts
368
366
  # EITHER the X-Session-API-Key header OR the workspace session cookie.
369
367
  # The cookie is required so that <iframe src> / <img src> embeds of
370
368
  # workspace artifacts work — browsers cannot attach custom headers to
371
369
  # those requests.
372
- workspace_dependencies = []
373
- if config.session_api_keys:
374
- workspace_dependencies.append(
375
- Depends(create_workspace_session_dependency(config))
376
- )
377
- workspace_api_router = APIRouter(prefix="/api", dependencies=workspace_dependencies)
370
+ workspace_api_router = APIRouter(
371
+ prefix="/api", dependencies=[Depends(check_workspace_session)]
372
+ )
378
373
  workspace_api_router.include_router(workspace_router)
379
374
  app.include_router(workspace_api_router)
380
375
 
@@ -592,7 +587,7 @@ def create_app(config: Config | None = None) -> FastAPI:
592
587
  app = _create_fastapi_instance(config)
593
588
  app.state.config = config
594
589
 
595
- _add_api_routes(app, config)
590
+ _add_api_routes(app)
596
591
  _setup_static_files(app, config)
597
592
  app.add_middleware(
598
593
  CORSDispatcher,
@@ -7,12 +7,14 @@ from uuid import UUID
7
7
 
8
8
  from fastapi import (
9
9
  APIRouter,
10
+ Depends,
10
11
  HTTPException,
11
12
  Query,
12
13
  status,
13
14
  )
14
15
 
15
- from openhands.agent_server.bash_service import get_default_bash_event_service
16
+ from openhands.agent_server.bash_service import BashEventService
17
+ from openhands.agent_server.dependencies import get_bash_event_service
16
18
  from openhands.agent_server.models import (
17
19
  BashCommand,
18
20
  BashEventBase,
@@ -25,7 +27,6 @@ from openhands.agent_server.server_details_router import update_last_execution_t
25
27
 
26
28
 
27
29
  bash_router = APIRouter(prefix="/bash", tags=["Bash"])
28
- bash_event_service = get_default_bash_event_service()
29
30
  logger = logging.getLogger(__name__)
30
31
 
31
32
 
@@ -53,6 +54,7 @@ async def search_bash_events(
53
54
  int,
54
55
  Query(title="The max number of results in the page", gt=0, lte=100),
55
56
  ] = 100,
57
+ bash_event_service: BashEventService = Depends(get_bash_event_service),
56
58
  ) -> BashEventPage:
57
59
  """Search / List bash event events"""
58
60
  assert limit > 0
@@ -73,7 +75,10 @@ async def search_bash_events(
73
75
  @bash_router.get(
74
76
  "/bash_events/{event_id}", responses={404: {"description": "Item not found"}}
75
77
  )
76
- async def get_bash_event(event_id: str) -> BashEventBase:
78
+ async def get_bash_event(
79
+ event_id: str,
80
+ bash_event_service: BashEventService = Depends(get_bash_event_service),
81
+ ) -> BashEventBase:
77
82
  """Get a bash event event given an id"""
78
83
  event = await bash_event_service.get_bash_event(event_id)
79
84
  if event is None:
@@ -84,6 +89,7 @@ async def get_bash_event(event_id: str) -> BashEventBase:
84
89
  @bash_router.get("/bash_events/")
85
90
  async def batch_get_bash_events(
86
91
  event_ids: list[str],
92
+ bash_event_service: BashEventService = Depends(get_bash_event_service),
87
93
  ) -> list[BashEventBase | None]:
88
94
  """Get a batch of bash event events given their ids, returning null for any
89
95
  missing item."""
@@ -92,7 +98,10 @@ async def batch_get_bash_events(
92
98
 
93
99
 
94
100
  @bash_router.post("/start_bash_command")
95
- async def start_bash_command(request: ExecuteBashRequest) -> BashCommand:
101
+ async def start_bash_command(
102
+ request: ExecuteBashRequest,
103
+ bash_event_service: BashEventService = Depends(get_bash_event_service),
104
+ ) -> BashCommand:
96
105
  """Execute a bash command in the background"""
97
106
  update_last_execution_time()
98
107
  command, _ = await bash_event_service.start_bash_command(request)
@@ -100,7 +109,10 @@ async def start_bash_command(request: ExecuteBashRequest) -> BashCommand:
100
109
 
101
110
 
102
111
  @bash_router.post("/execute_bash_command")
103
- async def execute_bash_command(request: ExecuteBashRequest) -> BashOutput:
112
+ async def execute_bash_command(
113
+ request: ExecuteBashRequest,
114
+ bash_event_service: BashEventService = Depends(get_bash_event_service),
115
+ ) -> BashOutput:
104
116
  """Execute a bash command and wait for a result"""
105
117
  update_last_execution_time()
106
118
  command, task = await bash_event_service.start_bash_command(request)
@@ -111,7 +123,9 @@ async def execute_bash_command(request: ExecuteBashRequest) -> BashOutput:
111
123
 
112
124
 
113
125
  @bash_router.delete("/bash_events")
114
- async def clear_all_bash_events() -> dict[str, int]:
126
+ async def clear_all_bash_events(
127
+ bash_event_service: BashEventService = Depends(get_bash_event_service),
128
+ ) -> dict[str, int]:
115
129
  """Clear all bash events from storage"""
116
130
  count = await bash_event_service.clear_all_events()
117
131
  return {"cleared_count": count}
@@ -261,11 +261,13 @@ def _resolve_agent_from_profile(
261
261
  DanglingMcpServerRef: A referenced MCP server is absent from the global config.
262
262
  ValueError: Profile load or settings validation failure.
263
263
  """
264
- from openhands.sdk.llm.llm_profile_store import LLMProfileStore
265
- from openhands.sdk.profiles.agent_profile_store import AgentProfileStore
264
+ from openhands.agent_server.persistence.store import (
265
+ get_agent_profile_store,
266
+ get_llm_profile_store,
267
+ )
266
268
  from openhands.sdk.profiles.resolver import ProfileNotFound, resolve_agent_profile
267
269
 
268
- store = AgentProfileStore()
270
+ store = get_agent_profile_store()
269
271
  profile_name = store.name_for_id(profile_id)
270
272
  if profile_name is None:
271
273
  raise ProfileNotFound(f"Agent profile with id '{profile_id}' not found")
@@ -281,7 +283,7 @@ def _resolve_agent_from_profile(
281
283
  f"Failed to load agent profile '{profile_name}': {exc}"
282
284
  ) from exc
283
285
 
284
- llm_store = LLMProfileStore()
286
+ llm_store = get_llm_profile_store()
285
287
  try:
286
288
  settings_config = resolve_agent_profile(
287
289
  profile, llm_store=llm_store, mcp_config=mcp_config, cipher=cipher
@@ -1343,9 +1345,11 @@ class AutoTitleSubscriber(Subscriber):
1343
1345
  return None
1344
1346
 
1345
1347
  try:
1346
- from openhands.sdk.llm.llm_profile_store import LLMProfileStore
1348
+ from openhands.agent_server.persistence.store import (
1349
+ get_llm_profile_store,
1350
+ )
1347
1351
 
1348
- profile_store = LLMProfileStore()
1352
+ profile_store = get_llm_profile_store()
1349
1353
  return profile_store.load(profile_name, cipher=self.service.cipher)
1350
1354
  except (FileNotFoundError, ValueError) as e:
1351
1355
  logger.warning(
@@ -3,6 +3,7 @@ from uuid import UUID
3
3
  from fastapi import Depends, HTTPException, Request, status
4
4
  from fastapi.security import APIKeyCookie, APIKeyHeader
5
5
 
6
+ from openhands.agent_server.bash_service import BashEventService
6
7
  from openhands.agent_server.config import Config
7
8
  from openhands.agent_server.conversation_service import ConversationService
8
9
  from openhands.agent_server.event_service import EventService
@@ -20,22 +21,26 @@ _WORKSPACE_SESSION_COOKIE = APIKeyCookie(
20
21
  )
21
22
 
22
23
 
23
- def create_session_api_key_dependency(config: Config):
24
- """Create a session API key dependency with the given config."""
24
+ def check_session_api_key(
25
+ request: Request,
26
+ session_api_key: str | None = Depends(_SESSION_API_KEY_HEADER),
27
+ ) -> None:
28
+ """Reject the request if the supplied key is not in the current session keys.
25
29
 
26
- def check_session_api_key(
27
- session_api_key: str | None = Depends(_SESSION_API_KEY_HEADER),
28
- ):
29
- """Check the session API key and throw an exception if incorrect. Having this as
30
- a dependency means it appears in OpenAPI Docs
31
- """
32
- if config.session_api_keys and session_api_key not in config.session_api_keys:
33
- raise HTTPException(status.HTTP_401_UNAUTHORIZED)
34
-
35
- return check_session_api_key
30
+ Reads ``session_api_keys`` from ``request.app.state.config`` at request time
31
+ so that keys delivered via ``POST /api/init`` take effect immediately without
32
+ restarting the server or re-registering routes.
33
+ """
34
+ config: Config = request.app.state.config
35
+ if config.session_api_keys and session_api_key not in config.session_api_keys:
36
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED)
36
37
 
37
38
 
38
- def create_workspace_session_dependency(config: Config):
39
+ def check_workspace_session(
40
+ request: Request,
41
+ header_key: str | None = Depends(_SESSION_API_KEY_HEADER),
42
+ cookie_key: str | None = Depends(_WORKSPACE_SESSION_COOKIE),
43
+ ) -> None:
39
44
  """Auth dependency for the workspace static-file routes.
40
45
 
41
46
  Accepts EITHER the standard ``X-Session-API-Key`` header OR the
@@ -46,28 +51,16 @@ def create_workspace_session_dependency(config: Config):
46
51
  frontend embeds workspace artifacts. The cookie is deliberately scoped
47
52
  to this router only; no other endpoint honors it.
48
53
  """
49
-
50
- def check_workspace_session(
51
- header_key: str | None = Depends(_SESSION_API_KEY_HEADER),
52
- cookie_key: str | None = Depends(_WORKSPACE_SESSION_COOKIE),
53
- ):
54
- if not config.session_api_keys:
54
+ config: Config = request.app.state.config
55
+ if not config.session_api_keys:
56
+ return
57
+ for candidate in (header_key, cookie_key):
58
+ if candidate and candidate in config.session_api_keys:
55
59
  return
56
- for candidate in (header_key, cookie_key):
57
- if candidate and candidate in config.session_api_keys:
58
- return
59
- raise HTTPException(status.HTTP_401_UNAUTHORIZED)
60
-
61
- return check_workspace_session
62
-
63
-
64
- def get_conversation_service(request: Request):
65
- """Get the conversation service from app state.
60
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED)
66
61
 
67
- This dependency ensures that the conversation service is properly initialized
68
- through the application lifespan context manager.
69
- """
70
62
 
63
+ def get_conversation_service(request: Request) -> ConversationService:
71
64
  service = getattr(request.app.state, "conversation_service", None)
72
65
  if service is None:
73
66
  raise HTTPException(
@@ -77,6 +70,16 @@ def get_conversation_service(request: Request):
77
70
  return service
78
71
 
79
72
 
73
+ def get_bash_event_service(request: Request) -> BashEventService:
74
+ service = getattr(request.app.state, "bash_event_service", None)
75
+ if service is None:
76
+ raise HTTPException(
77
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
78
+ detail="Bash event service is not available",
79
+ )
80
+ return service
81
+
82
+
80
83
  async def get_event_service(
81
84
  conversation_id: UUID,
82
85
  conversation_service: ConversationService = Depends(get_conversation_service),
@@ -8,6 +8,11 @@ ARG USERNAME=openhands
8
8
  ARG UID=10001
9
9
  ARG GID=10001
10
10
  ARG PORT=8000
11
+ # Opt-in build flag for the Vertex AI extra (`openhands-sdk[vertex]`). Off by
12
+ # default to keep the published image lean. Pass `--build-arg ENABLE_VERTEX=1`
13
+ # to bundle google-cloud-aiplatform so the resulting binary supports
14
+ # `vertex_ai/*` partner models (MiniMax, Qwen, Kimi MaaS endpoints).
15
+ ARG ENABLE_VERTEX=0
11
16
 
12
17
  ####################################################################################
13
18
  # Builder (source mode)
@@ -26,7 +31,7 @@ ARG PORT=8000
26
31
  # See OpenHands/software-agent-sdk#2761.
27
32
  ####################################################################################
28
33
  FROM python:3.13-bookworm AS builder
29
- ARG USERNAME UID GID
34
+ ARG USERNAME UID GID ENABLE_VERTEX
30
35
  ENV UV_PROJECT_ENVIRONMENT=/agent-server/.venv
31
36
  ENV UV_PYTHON_INSTALL_DIR=/agent-server/uv-managed-python
32
37
 
@@ -48,9 +53,11 @@ COPY --chown=${USERNAME}:${USERNAME} openhands-tools ./openhands-tools
48
53
  COPY --chown=${USERNAME}:${USERNAME} openhands-workspace ./openhands-workspace
49
54
  COPY --chown=${USERNAME}:${USERNAME} openhands-agent-server ./openhands-agent-server
50
55
  RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
56
+ EXTRA_FLAGS=""; \
57
+ if [ "$ENABLE_VERTEX" = "1" ]; then EXTRA_FLAGS="--extra vertex"; fi; \
51
58
  uv python install 3.13 && \
52
59
  uv venv --python-preference only-managed --python 3.13 .venv && \
53
- uv sync --frozen --no-editable --managed-python --extra boto3 && \
60
+ uv sync --frozen --no-editable --managed-python --extra boto3 $EXTRA_FLAGS && \
54
61
  readlink -f .venv/bin/python | grep -q '^/agent-server/uv-managed-python/'
55
62
 
56
63
  ####################################################################################
@@ -58,11 +65,13 @@ RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
58
65
  # We run pyinstaller here to produce openhands-agent-server
59
66
  ####################################################################################
60
67
  FROM builder AS binary-builder
61
- ARG USERNAME UID GID
68
+ ARG USERNAME UID GID ENABLE_VERTEX
62
69
 
63
70
  # We need --dev for pyinstaller
64
71
  RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
65
- uv sync --frozen --dev --no-editable --extra boto3
72
+ EXTRA_FLAGS=""; \
73
+ if [ "$ENABLE_VERTEX" = "1" ]; then EXTRA_FLAGS="--extra vertex"; fi; \
74
+ uv sync --frozen --dev --no-editable --extra boto3 $EXTRA_FLAGS
66
75
 
67
76
  RUN --mount=type=cache,target=/home/${USERNAME}/.cache,uid=${UID},gid=${GID} \
68
77
  uv run pyinstaller openhands-agent-server/openhands/agent_server/agent-server.spec
@@ -6,6 +6,8 @@ from datetime import datetime
6
6
  from pathlib import Path
7
7
  from uuid import UUID, uuid4
8
8
 
9
+ from pydantic import ValidationError
10
+
9
11
  from openhands.agent_server.conversation_lease import (
10
12
  ConversationLease,
11
13
  ConversationOwnershipLostError,
@@ -20,6 +22,7 @@ from openhands.agent_server.pub_sub import PubSub, Subscriber
20
22
  from openhands.sdk import LLM, AgentBase, Event, Message, TextContent, get_logger
21
23
  from openhands.sdk.agent import ACPAgent
22
24
  from openhands.sdk.conversation.base import BaseConversation
25
+ from openhands.sdk.conversation.events_list_base import EventsListBase
23
26
  from openhands.sdk.conversation.goal import (
24
27
  GoalController,
25
28
  GoalDone,
@@ -217,6 +220,18 @@ class EventService:
217
220
  return False
218
221
  return True
219
222
 
223
+ def _get_searchable_event(self, events: EventsListBase, index: int) -> Event | None:
224
+ try:
225
+ return events[index]
226
+ except (FileNotFoundError, UnicodeDecodeError, ValidationError) as exc:
227
+ logger.warning(
228
+ "Skipping unreadable event at index %d for conversation %s (%s)",
229
+ index,
230
+ self.stored.id,
231
+ type(exc).__name__,
232
+ )
233
+ return None
234
+
220
235
  def _search_events_sync(
221
236
  self,
222
237
  page_id: str | None = None,
@@ -272,7 +287,8 @@ class EventService:
272
287
  start_index = None
273
288
  else:
274
289
  for i in range(total):
275
- if events[i].id == page_id:
290
+ event = self._get_searchable_event(events, i)
291
+ if event is not None and event.id == page_id:
276
292
  start_index = i
277
293
  break
278
294
  if start_index is None:
@@ -286,7 +302,9 @@ class EventService:
286
302
  items: list[Event] = []
287
303
  next_page_id: str | None = None
288
304
  for i in indices:
289
- event = events[i]
305
+ event = self._get_searchable_event(events, i)
306
+ if event is None:
307
+ continue
290
308
  if not self._event_matches_filters(
291
309
  event, kind, source, body, timestamp_gte_str, timestamp_lt_str
292
310
  ):
@@ -360,7 +378,10 @@ class EventService:
360
378
  timestamp_lt_str = timestamp__lt.isoformat() if timestamp__lt else None
361
379
 
362
380
  count = 0
363
- for event in events:
381
+ for i in range(len(events)):
382
+ event = self._get_searchable_event(events, i)
383
+ if event is None:
384
+ continue
364
385
  if self._event_matches_filters(
365
386
  event, kind, source, body, timestamp_gte_str, timestamp_lt_str
366
387
  ):
@@ -151,17 +151,20 @@ async def download_file_query(
151
151
  return await _download_file(path)
152
152
 
153
153
 
154
- def _list_home_favorites(home: Path, limit: int = 50) -> list[FileBrowserEntry]:
155
- """Top-level visible directories inside the user's home, alphabetised.
156
-
157
- Hidden entries (names starting with '.') and symlinks are skipped so the
158
- list matches what ``search_subdirs`` returns for the same path.
154
+ def _list_home_favorites(
155
+ home: Path, limit: int = 50, include_hidden: bool = False
156
+ ) -> list[FileBrowserEntry]:
157
+ """Top-level directories inside the user's home, alphabetised.
158
+
159
+ Symlinks are skipped. Hidden entries (names starting with '.') are skipped
160
+ unless ``include_hidden`` is True, so the list matches what
161
+ ``search_subdirs`` returns for the same path and the same flag.
159
162
  """
160
163
  entries: list[FileBrowserEntry] = []
161
164
  try:
162
165
  with os.scandir(home) as scanner:
163
166
  for entry in scanner:
164
- if entry.name.startswith("."):
167
+ if not include_hidden and entry.name.startswith("."):
165
168
  continue
166
169
  try:
167
170
  if not entry.is_dir(follow_symlinks=False):
@@ -197,18 +200,24 @@ def _list_root_locations() -> list[FileBrowserEntry]:
197
200
 
198
201
 
199
202
  @file_router.get("/home")
200
- async def get_home_directory() -> HomeResponse:
203
+ async def get_home_directory(
204
+ include_hidden: Annotated[
205
+ bool,
206
+ Query(description="Include hidden top-level directories in `favorites`"),
207
+ ] = False,
208
+ ) -> HomeResponse:
201
209
  """Return the agent-server user's home directory and dynamic sidebar lists.
202
210
 
203
- ``favorites`` is the set of visible top-level directories actually present
204
- in the user's home (so it reflects the real environment instead of a
205
- hardcoded list of names that may not exist). ``locations`` is the set of
206
- filesystem roots '/' on POSIX or available drive letters on Windows.
211
+ ``favorites`` is the set of top-level directories actually present in the
212
+ user's home (so it reflects the real environment instead of a hardcoded
213
+ list of names that may not exist). Hidden directories are included only
214
+ when ``include_hidden`` is True. ``locations`` is the set of filesystem
215
+ roots — '/' on POSIX or available drive letters on Windows.
207
216
  """
208
217
  home = Path.home()
209
218
  return HomeResponse(
210
219
  home=str(home),
211
- favorites=_list_home_favorites(home),
220
+ favorites=_list_home_favorites(home, include_hidden=include_hidden),
212
221
  locations=_list_root_locations(),
213
222
  )
214
223
 
@@ -227,12 +236,17 @@ async def search_subdirs(
227
236
  int,
228
237
  Query(title="The max number of results in the page", gt=0, lte=100),
229
238
  ] = 100,
239
+ include_hidden: Annotated[
240
+ bool,
241
+ Query(title="Include hidden subdirectories (names starting with '.')"),
242
+ ] = False,
230
243
  ) -> SubdirectoryPage:
231
244
  """Search / List immediate subdirectories of `path`.
232
245
 
233
- Used by the GUI's workspace picker. Hidden entries (names starting with '.')
234
- and symlinks are skipped. Files are skipped. Returns absolute paths so the
235
- GUI can use a result directly as ``workspace.working_dir``.
246
+ Used by the GUI's workspace picker. Symlinks and files are skipped. Hidden
247
+ entries (names starting with '.') are skipped unless ``include_hidden`` is
248
+ True. Returns absolute paths so the GUI can use a result directly as
249
+ ``workspace.working_dir``.
236
250
 
237
251
  Results are sorted case-insensitively by name and paginated. ``page_id`` is
238
252
  the ``next_page_id`` returned by the previous page (the lowercase name of
@@ -262,7 +276,7 @@ async def search_subdirs(
262
276
  try:
263
277
  with os.scandir(target) as scanner:
264
278
  for entry in scanner:
265
- if entry.name.startswith("."):
279
+ if not include_hidden and entry.name.startswith("."):
266
280
  continue
267
281
  try:
268
282
  if not entry.is_dir(follow_symlinks=False):
@@ -21,6 +21,7 @@ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status
21
21
  from fastapi.security import APIKeyHeader
22
22
  from pydantic import BaseModel, ConfigDict, Field, SecretStr
23
23
 
24
+ from openhands.agent_server.bash_service import BashEventService
24
25
  from openhands.agent_server.config import Config, WebhookSpec
25
26
  from openhands.agent_server.conversation_service import ConversationService
26
27
  from openhands.agent_server.server_details_router import mark_initialization_complete
@@ -175,6 +176,7 @@ class InitService:
175
176
  self._error: str | None = None
176
177
  self._lock = asyncio.Lock()
177
178
  self._entered_service: ConversationService | None = None
179
+ self._entered_bash_service: BashEventService | None = None
178
180
 
179
181
  @property
180
182
  def state(self) -> InitState:
@@ -209,10 +211,22 @@ class InitService:
209
211
  service = ConversationService.get_instance(new_config)
210
212
  cs_mod._conversation_service = service
211
213
 
214
+ bash_svc = BashEventService(bash_events_dir=new_config.bash_events_dir)
215
+ await bash_svc.__aenter__()
216
+ self._entered_bash_service = bash_svc
217
+
212
218
  await service.__aenter__()
213
219
  self._entered_service = service
214
220
  self._app.state.config = new_config
215
221
  self._app.state.conversation_service = service
222
+ self._app.state.bash_event_service = bash_svc
223
+
224
+ # Re-derive root_path from the merged config so Doc URLS are valid
225
+ from openhands.agent_server.api import _get_root_path
226
+
227
+ new_root_path = _get_root_path(new_config)
228
+ self._app.root_path = new_root_path
229
+
216
230
  mark_initialization_complete()
217
231
  self._state = "ready"
218
232
  logger.info("deferred_init: server transitioned to ready")
@@ -235,6 +249,9 @@ class InitService:
235
249
  if self._entered_service is not None:
236
250
  await self._entered_service.__aexit__(None, None, None)
237
251
  self._entered_service = None
252
+ if self._entered_bash_service is not None:
253
+ await self._entered_bash_service.__aexit__(None, None, None)
254
+ self._entered_bash_service = None
238
255
 
239
256
 
240
257
  def get_init_service(request: Request) -> InitService:
@@ -28,7 +28,11 @@ _SESSION_API_KEY_HEADER = APIKeyHeader(name="X-Session-API-Key", auto_error=Fals
28
28
  _AUTHORIZATION_HEADER = HTTPBearer(auto_error=False)
29
29
 
30
30
 
31
- def create_openai_api_key_dependency(config: Config):
31
+ def check_openai_api_key(
32
+ request: Request,
33
+ session_api_key: str | None = Depends(_SESSION_API_KEY_HEADER),
34
+ authorization: HTTPAuthorizationCredentials | None = Depends(_AUTHORIZATION_HEADER),
35
+ ) -> None:
32
36
  """Accept the same session key through OpenHands and OpenAI auth shapes.
33
37
 
34
38
  ``X-Session-API-Key`` preserves compatibility with existing agent-server
@@ -37,24 +41,19 @@ def create_openai_api_key_dependency(config: Config):
37
41
  ``config.session_api_keys``; this does not introduce a second credential
38
42
  system. When no session keys are configured, the local server remains
39
43
  unauthenticated like the existing agent-server API.
40
- """
41
44
 
42
- def check_openai_api_key(
43
- session_api_key: str | None = Depends(_SESSION_API_KEY_HEADER),
44
- authorization: HTTPAuthorizationCredentials | None = Depends(
45
- _AUTHORIZATION_HEADER
46
- ),
47
- ) -> None:
48
- if not config.session_api_keys:
49
- return
50
- bearer_token = authorization.credentials if authorization else None
51
- if session_api_key in config.session_api_keys:
52
- return
53
- if bearer_token in config.session_api_keys:
54
- return
55
- raise HTTPException(status.HTTP_401_UNAUTHORIZED)
56
-
57
- return check_openai_api_key
45
+ Reads config from ``request.app.state`` at request time so that keys
46
+ delivered via ``POST /api/init`` take effect immediately.
47
+ """
48
+ config: Config = request.app.state.config
49
+ if not config.session_api_keys:
50
+ return
51
+ bearer_token = authorization.credentials if authorization else None
52
+ if session_api_key in config.session_api_keys:
53
+ return
54
+ if bearer_token in config.session_api_keys:
55
+ return
56
+ raise HTTPException(status.HTTP_401_UNAUTHORIZED)
58
57
 
59
58
 
60
59
  def _get_config(request: Request) -> Config:
@@ -25,7 +25,11 @@ from openhands.agent_server.openai.models import (
25
25
  OpenAIResponseMessage,
26
26
  OpenAIUsage,
27
27
  )
28
- from openhands.agent_server.persistence import PersistedSettings, get_settings_store
28
+ from openhands.agent_server.persistence import (
29
+ PersistedSettings,
30
+ get_llm_profile_store,
31
+ get_settings_store,
32
+ )
29
33
  from openhands.sdk import LLM, Message
30
34
  from openhands.sdk.context.agent_context import AgentContext
31
35
  from openhands.sdk.conversation.request import (
@@ -36,7 +40,6 @@ from openhands.sdk.conversation.state import (
36
40
  ConversationExecutionStatus,
37
41
  ConversationState,
38
42
  )
39
- from openhands.sdk.llm.llm_profile_store import LLMProfileStore
40
43
  from openhands.sdk.llm.message import ImageContent, TextContent
41
44
  from openhands.sdk.settings import ACPAgentSettings, OpenHandsAgentSettings
42
45
  from openhands.sdk.workspace import LocalWorkspace
@@ -66,7 +69,7 @@ def _profile_name_from_model(model: str) -> str:
66
69
 
67
70
  def _load_profile_llm(profile_name: str, config: Config) -> LLM:
68
71
  try:
69
- return LLMProfileStore().load(profile_name, cipher=config.cipher)
72
+ return get_llm_profile_store().load(profile_name, cipher=config.cipher)
70
73
  except FileNotFoundError:
71
74
  raise HTTPException(
72
75
  status_code=status.HTTP_404_NOT_FOUND,
@@ -369,7 +372,7 @@ def iter_openai_chat_completion_sse(
369
372
 
370
373
  async def list_openai_models() -> OpenAIModelListResponse:
371
374
  try:
372
- profiles = LLMProfileStore().list_summaries()
375
+ profiles = get_llm_profile_store().list_summaries()
373
376
  except TimeoutError:
374
377
  raise HTTPException(
375
378
  status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
@@ -25,6 +25,8 @@ from openhands.agent_server.persistence.store import (
25
25
  SecretsStore,
26
26
  SettingsStore,
27
27
  WorkspacesStore,
28
+ get_agent_profile_store,
29
+ get_llm_profile_store,
28
30
  get_secrets_store,
29
31
  get_settings_store,
30
32
  get_workspaces_store,
@@ -52,6 +54,8 @@ __all__ = [
52
54
  "SecretsStore",
53
55
  "SettingsStore",
54
56
  "WorkspacesStore",
57
+ "get_agent_profile_store",
58
+ "get_llm_profile_store",
55
59
  "get_secrets_store",
56
60
  "get_settings_store",
57
61
  "get_workspaces_store",
@@ -27,7 +27,9 @@ from openhands.agent_server.persistence.models import (
27
27
  PersistedWorkspaces,
28
28
  Secrets,
29
29
  )
30
+ from openhands.sdk.llm.llm_profile_store import LLMProfileStore
30
31
  from openhands.sdk.logger import get_logger
32
+ from openhands.sdk.profiles.agent_profile_store import AgentProfileStore
31
33
  from openhands.sdk.utils.cipher import Cipher
32
34
 
33
35
 
@@ -687,6 +689,8 @@ class FileWorkspacesStore(WorkspacesStore):
687
689
  _settings_store: FileSettingsStore | None = None
688
690
  _secrets_store: FileSecretsStore | None = None
689
691
  _workspaces_store: FileWorkspacesStore | None = None
692
+ _llm_profile_store: LLMProfileStore | None = None
693
+ _agent_profile_store: AgentProfileStore | None = None
690
694
  _store_lock = threading.Lock()
691
695
 
692
696
 
@@ -704,6 +708,20 @@ def _get_persistence_dir(config: Config | None = None) -> Path:
704
708
  return DEFAULT_PERSISTENCE_DIR
705
709
 
706
710
 
711
+ def _get_profile_persistence_dir() -> Path:
712
+ """Get the base dir for LLM/agent profile stores.
713
+
714
+ Profiles can hold credentials (LLM API keys), so absent
715
+ ``OH_PERSISTENCE_DIR`` they fall back to the user's ``~/.openhands``
716
+ rather than the workspace-relative agent-server default, keeping bare
717
+ local profile secrets in the expected user config directory.
718
+ """
719
+ env_dir = os.environ.get("OH_PERSISTENCE_DIR")
720
+ if env_dir:
721
+ return Path(env_dir)
722
+ return Path.home() / ".openhands"
723
+
724
+
707
725
  def _get_cipher(config: Config | None = None) -> Cipher | None:
708
726
  """Get cipher from config for encrypting secrets."""
709
727
  if config is not None:
@@ -794,10 +812,53 @@ def get_workspaces_store(config: Config | None = None) -> FileWorkspacesStore:
794
812
  return _workspaces_store
795
813
 
796
814
 
815
+ def get_llm_profile_store() -> LLMProfileStore:
816
+ """Get the global ``LLMProfileStore`` instance (thread-safe).
817
+
818
+ Stored at ``<dir>/profiles`` where ``<dir>`` is ``OH_PERSISTENCE_DIR``
819
+ when set, else ``~/.openhands``. This honors isolated agent-server
820
+ instances while keeping bare local profile secrets in the user's
821
+ config directory (never workspace-relative). See
822
+ :func:`_get_profile_persistence_dir`.
823
+ """
824
+ global _llm_profile_store
825
+ if _llm_profile_store is not None:
826
+ return _llm_profile_store
827
+
828
+ with _store_lock:
829
+ if _llm_profile_store is None:
830
+ _llm_profile_store = LLMProfileStore(
831
+ base_dir=_get_profile_persistence_dir() / "profiles",
832
+ )
833
+ return _llm_profile_store
834
+
835
+
836
+ def get_agent_profile_store() -> AgentProfileStore:
837
+ """Get the global ``AgentProfileStore`` instance (thread-safe).
838
+
839
+ Stored at ``<dir>/agent-profiles`` where ``<dir>`` is resolved by
840
+ :func:`_get_profile_persistence_dir`, for the same reason as
841
+ :func:`get_llm_profile_store`.
842
+ """
843
+ global _agent_profile_store
844
+ if _agent_profile_store is not None:
845
+ return _agent_profile_store
846
+
847
+ with _store_lock:
848
+ if _agent_profile_store is None:
849
+ _agent_profile_store = AgentProfileStore(
850
+ base_dir=_get_profile_persistence_dir() / "agent-profiles",
851
+ )
852
+ return _agent_profile_store
853
+
854
+
797
855
  def reset_stores() -> None:
798
856
  """Reset global store instances (for testing)."""
799
857
  global _settings_store, _secrets_store, _workspaces_store
858
+ global _llm_profile_store, _agent_profile_store
800
859
  with _store_lock:
801
860
  _settings_store = None
802
861
  _secrets_store = None
803
862
  _workspaces_store = None
863
+ _llm_profile_store = None
864
+ _agent_profile_store = None
@@ -17,17 +17,17 @@ from openhands.agent_server._secrets_exposure import (
17
17
  )
18
18
  from openhands.agent_server.persistence import (
19
19
  PersistedSettings,
20
+ get_agent_profile_store,
21
+ get_llm_profile_store,
20
22
  get_settings_store,
21
23
  )
22
24
  from openhands.sdk.llm import LLM
23
25
  from openhands.sdk.llm.llm_profile_store import (
24
26
  PROFILE_NAME_PATTERN,
25
- LLMProfileStore,
26
27
  ProfileLimitExceeded,
27
28
  )
28
29
  from openhands.sdk.logger import get_logger
29
30
  from openhands.sdk.profiles import (
30
- AgentProfileStore,
31
31
  ProfileReferenced,
32
32
  delete_llm_profile,
33
33
  rename_llm_profile,
@@ -140,7 +140,7 @@ async def list_profiles(request: Request) -> ProfileListResponse:
140
140
  settings_store = get_settings_store(config)
141
141
  settings = settings_store.load() or PersistedSettings()
142
142
 
143
- store = LLMProfileStore()
143
+ store = get_llm_profile_store()
144
144
  with _store_errors():
145
145
  summaries = store.list_summaries()
146
146
 
@@ -162,7 +162,7 @@ async def get_profile(request: Request, name: ProfileName) -> ProfileDetailRespo
162
162
  expose_mode = parse_expose_secrets_header(request)
163
163
  cipher = get_cipher(request)
164
164
 
165
- store = LLMProfileStore()
165
+ store = get_llm_profile_store()
166
166
  try:
167
167
  with _store_errors():
168
168
  llm = store.load(name, cipher=cipher)
@@ -206,7 +206,7 @@ async def save_profile(
206
206
  """
207
207
  cipher = get_cipher(request)
208
208
  llm = decrypt_incoming_llm_secrets(body.llm, cipher) if cipher else body.llm
209
- store = LLMProfileStore()
209
+ store = get_llm_profile_store()
210
210
  try:
211
211
  with _store_errors():
212
212
  store.save(
@@ -238,8 +238,8 @@ async def delete_profile(
238
238
  Guarded by the agent-profile FK: returns 409 naming the referrers if any
239
239
  ``AgentProfile`` still cites this LLM profile via ``llm_profile_ref``.
240
240
  """
241
- store = LLMProfileStore()
242
- agent_store = AgentProfileStore()
241
+ store = get_llm_profile_store()
242
+ agent_store = get_agent_profile_store()
243
243
  try:
244
244
  with _store_errors():
245
245
  delete_llm_profile(agent_store, store, name)
@@ -266,8 +266,8 @@ async def rename_profile(
266
266
  setting is updated to the new name. Any ``AgentProfile.llm_profile_ref``
267
267
  citing the old name is cascaded to the new name in lock-step.
268
268
  """
269
- store = LLMProfileStore()
270
- agent_store = AgentProfileStore()
269
+ store = get_llm_profile_store()
270
+ agent_store = get_agent_profile_store()
271
271
  try:
272
272
  with _store_errors():
273
273
  rename_llm_profile(agent_store, store, name, body.new_name)
@@ -323,7 +323,7 @@ async def activate_profile(
323
323
  config = get_config(request)
324
324
 
325
325
  # Load the profile
326
- profile_store = LLMProfileStore()
326
+ profile_store = get_llm_profile_store()
327
327
  try:
328
328
  with _store_errors():
329
329
  llm = profile_store.load(name, cipher=cipher)
@@ -28,9 +28,13 @@ from fastapi import (
28
28
  )
29
29
  from starlette.websockets import WebSocketState
30
30
 
31
- from openhands.agent_server.bash_service import get_default_bash_event_service
31
+ from openhands.agent_server.bash_service import (
32
+ BashEventService,
33
+ get_default_bash_event_service,
34
+ )
32
35
  from openhands.agent_server.config import Config, get_default_config
33
36
  from openhands.agent_server.conversation_service import (
37
+ ConversationService,
34
38
  get_default_conversation_service,
35
39
  )
36
40
  from openhands.agent_server.event_router import normalize_datetime_to_server_timezone
@@ -64,6 +68,38 @@ def _get_config(websocket: WebSocket) -> Config:
64
68
  return get_default_config()
65
69
 
66
70
 
71
+ def _get_conversation_service(websocket: WebSocket) -> ConversationService:
72
+ """Return the ConversationService for this FastAPI app instance.
73
+
74
+ Looks up ``app.state.conversation_service`` at request time so that the
75
+ service delivered via ``POST /api/init`` (deferred-init / dormant mode)
76
+ is used instead of the module-level default captured at import. When
77
+ ``app.state`` is not configured (e.g. when sockets.py is imported as a
78
+ library without a lifespan), falls back to the module-level singleton,
79
+ which keeps the behaviour of existing tests that patch the module-level
80
+ variable.
81
+ """
82
+ service = getattr(websocket.app.state, "conversation_service", None)
83
+ if isinstance(service, ConversationService):
84
+ return service
85
+ return conversation_service
86
+
87
+
88
+ def _get_bash_event_service(websocket: WebSocket) -> BashEventService:
89
+ """Return the BashEventService for this FastAPI app instance.
90
+
91
+ Looks up ``app.state.bash_event_service`` at request time so that the
92
+ service delivered via ``POST /api/init`` (deferred-init / dormant mode)
93
+ is used instead of the module-level default captured at import. When
94
+ ``app.state`` is not configured (e.g. when sockets.py is imported as a
95
+ library without a lifespan), falls back to the module-level singleton.
96
+ """
97
+ service = getattr(websocket.app.state, "bash_event_service", None)
98
+ if isinstance(service, BashEventService):
99
+ return service
100
+ return bash_event_service
101
+
102
+
67
103
  def _resolve_websocket_session_api_key(
68
104
  websocket: WebSocket,
69
105
  session_api_key: str | None,
@@ -242,7 +278,8 @@ async def events_socket(
242
278
  return
243
279
 
244
280
  logger.info(f"Event Websocket Connected: {conversation_id}")
245
- event_service = await conversation_service.get_event_service(conversation_id)
281
+ conv_service = _get_conversation_service(websocket)
282
+ event_service = await conv_service.get_event_service(conversation_id)
246
283
  if event_service is None:
247
284
  logger.warning(f"Converation not found: {conversation_id}")
248
285
  await websocket.close(code=4004, reason="Conversation not found")
@@ -372,9 +409,10 @@ async def bash_events_socket(
372
409
  if not await _accept_authenticated_websocket(websocket, session_api_key):
373
410
  return
374
411
 
412
+ bash_service = _get_bash_event_service(websocket)
375
413
  logger.info("Bash Websocket Connected")
376
414
  try:
377
- subscriber_id = await bash_event_service.subscribe_to_events(
415
+ subscriber_id = await bash_service.subscribe_to_events(
378
416
  _BashWebSocketSubscriber(websocket)
379
417
  )
380
418
  except MaxSubscribersError:
@@ -392,7 +430,7 @@ async def bash_events_socket(
392
430
  # Resend all existing events if requested
393
431
  if effective_mode == "all":
394
432
  logger.info("Resending bash events")
395
- async for event in page_iterator(bash_event_service.search_bash_events):
433
+ async for event in page_iterator(bash_service.search_bash_events):
396
434
  await _send_bash_event(event, websocket)
397
435
 
398
436
  while True:
@@ -401,7 +439,7 @@ async def bash_events_socket(
401
439
  data = await websocket.receive_json()
402
440
  logger.info("Received bash request")
403
441
  request = ExecuteBashRequest.model_validate(data)
404
- await bash_event_service.start_bash_command(request)
442
+ await bash_service.start_bash_command(request)
405
443
  except WebSocketDisconnect:
406
444
  logger.info("Bash websocket disconnected")
407
445
  return
@@ -428,7 +466,7 @@ async def bash_events_socket(
428
466
  await _safe_close_websocket(websocket)
429
467
  return
430
468
  finally:
431
- await bash_event_service.unsubscribe_from_events(subscriber_id)
469
+ await bash_service.unsubscribe_from_events(subscriber_id)
432
470
 
433
471
 
434
472
  async def _send_event(event: Event, websocket: WebSocket):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.29.0
3
+ Version: 1.29.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "openhands-agent-server"
3
- version = "1.29.0"
3
+ version = "1.29.2"
4
4
  description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
5
5
 
6
6
  requires-python = ">=3.12"