openhands-agent-server 1.48.0__tar.gz → 1.49.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 (95) hide show
  1. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/PKG-INFO +1 -1
  2. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/api.py +26 -16
  3. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/config.py +7 -0
  4. openhands_agent_server-1.49.0/openhands/agent_server/conversation_registry.py +74 -0
  5. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/conversation_router.py +3 -0
  6. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/conversation_service.py +53 -12
  7. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/docker/build.py +6 -4
  8. openhands_agent_server-1.49.0/openhands/agent_server/docker_runtime/__init__.py +1 -0
  9. openhands_agent_server-1.49.0/openhands/agent_server/docker_runtime/mediation.py +112 -0
  10. openhands_agent_server-1.49.0/openhands/agent_server/docker_runtime/provisioning.py +163 -0
  11. openhands_agent_server-1.49.0/openhands/agent_server/docker_runtime/proxy.py +253 -0
  12. openhands_agent_server-1.49.0/openhands/agent_server/docker_runtime/registry.py +319 -0
  13. openhands_agent_server-1.49.0/openhands/agent_server/docker_runtime/routers.py +345 -0
  14. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/init_router.py +14 -1
  15. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/runtime_router.py +1 -3
  16. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/server_details_router.py +11 -4
  17. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/settings_router.py +16 -2
  18. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/sockets.py +4 -3
  19. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands_agent_server.egg-info/PKG-INFO +1 -1
  20. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands_agent_server.egg-info/SOURCES.txt +14 -2
  21. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/pyproject.toml +1 -1
  22. openhands_agent_server-1.48.0/openhands/agent_server/desktop_router.py +0 -28
  23. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/__init__.py +0 -0
  24. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/__main__.py +0 -0
  25. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/_secret_redaction.py +0 -0
  26. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/_secrets_exposure.py +0 -0
  27. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/agent_profiles_router.py +0 -0
  28. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/auth_router.py +0 -0
  29. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/bash_router.py +0 -0
  30. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/bash_service.py +0 -0
  31. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/canvas_extensions/__init__.py +0 -0
  32. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/canvas_extensions/installed.py +0 -0
  33. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/canvas_extensions/manifest.py +0 -0
  34. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/canvas_extensions_router.py +0 -0
  35. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/conversation_lease.py +0 -0
  36. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/credential_binding.py +0 -0
  37. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/dependencies.py +0 -0
  38. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/docker/Dockerfile +0 -0
  39. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/env_parser.py +0 -0
  40. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/event_router.py +0 -0
  41. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/event_service.py +0 -0
  42. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/file_router.py +0 -0
  43. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/git_router.py +0 -0
  44. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/hooks_router.py +0 -0
  45. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/hooks_service.py +0 -0
  46. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/llm_router.py +0 -0
  47. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/logging_config.py +0 -0
  48. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/mcp_oauth_store.py +0 -0
  49. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/mcp_router.py +0 -0
  50. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/middleware.py +0 -0
  51. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/models.py +0 -0
  52. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/openai/__init__.py +0 -0
  53. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/openai/models.py +0 -0
  54. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/openai/router.py +0 -0
  55. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/openai/service.py +0 -0
  56. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/openapi.py +0 -0
  57. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/persistence/__init__.py +0 -0
  58. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/persistence/models.py +0 -0
  59. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/persistence/store.py +0 -0
  60. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/plugins_router.py +0 -0
  61. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/plugins_service.py +0 -0
  62. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/profiles_router.py +0 -0
  63. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/provider_connections_router.py +0 -0
  64. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/pub_sub.py +0 -0
  65. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/py.typed +0 -0
  66. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/session_protocol.py +0 -0
  67. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/session_socket.py +0 -0
  68. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/skills_router.py +0 -0
  69. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/skills_service.py +0 -0
  70. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/sub_agents_router.py +0 -0
  71. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/__init__.py +0 -0
  72. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/factory.py +0 -0
  73. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/http_exporter.py +0 -0
  74. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/models.py +0 -0
  75. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/policy.py +0 -0
  76. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/posthog_exporter.py +0 -0
  77. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/sanitizer.py +0 -0
  78. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/service.py +0 -0
  79. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/sink.py +0 -0
  80. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry/subscriber.py +0 -0
  81. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/telemetry_types.py +0 -0
  82. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/tool_preload_service.py +0 -0
  83. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/tool_router.py +0 -0
  84. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/utils.py +0 -0
  85. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/vscode_extensions/openhands-settings/extension.js +0 -0
  86. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/vscode_extensions/openhands-settings/package.json +0 -0
  87. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/vscode_router.py +0 -0
  88. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/vscode_service.py +0 -0
  89. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/workspace_router.py +0 -0
  90. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands/agent_server/workspaces_router.py +0 -0
  91. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands_agent_server.egg-info/dependency_links.txt +0 -0
  92. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands_agent_server.egg-info/entry_points.txt +0 -0
  93. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands_agent_server.egg-info/requires.txt +0 -0
  94. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/openhands_agent_server.egg-info/top_level.txt +0 -0
  95. {openhands_agent_server-1.48.0 → openhands_agent_server-1.49.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands-agent-server
3
- Version: 1.48.0
3
+ Version: 1.49.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
@@ -26,7 +26,13 @@ from openhands.agent_server.config import (
26
26
  Config,
27
27
  get_default_config,
28
28
  )
29
- from openhands.agent_server.conversation_router import conversation_router
29
+ from openhands.agent_server.conversation_registry import (
30
+ create_conversation_registry,
31
+ )
32
+ from openhands.agent_server.conversation_router import (
33
+ conversation_catalog_router,
34
+ conversation_router,
35
+ )
30
36
  from openhands.agent_server.conversation_service import (
31
37
  CredentialBindingActivationRequired,
32
38
  get_default_conversation_service,
@@ -38,8 +44,6 @@ from openhands.agent_server.dependencies import (
38
44
  check_session_api_key,
39
45
  check_workspace_session,
40
46
  )
41
- from openhands.agent_server.desktop_router import desktop_router
42
- from openhands.agent_server.event_router import event_router
43
47
  from openhands.agent_server.file_router import file_discovery_router, file_router
44
48
  from openhands.agent_server.git_router import git_router
45
49
  from openhands.agent_server.hooks_router import hooks_router
@@ -60,16 +64,13 @@ from openhands.agent_server.profiles_router import profiles_router
60
64
  from openhands.agent_server.provider_connections_router import (
61
65
  provider_connections_router,
62
66
  )
63
- from openhands.agent_server.runtime_router import create_runtime_router
64
67
  from openhands.agent_server.server_details_router import (
65
68
  get_server_info,
66
69
  mark_initialization_complete,
67
70
  server_details_router,
68
71
  )
69
- from openhands.agent_server.session_socket import session_router
70
72
  from openhands.agent_server.settings_router import settings_router
71
73
  from openhands.agent_server.skills_router import skills_router
72
- from openhands.agent_server.sockets import sockets_router
73
74
  from openhands.agent_server.sub_agents_router import sub_agents_router
74
75
  from openhands.agent_server.telemetry import (
75
76
  build_telemetry_sink,
@@ -92,7 +93,6 @@ from openhands.agent_server.tool_preload_service import get_tool_preload_service
92
93
  from openhands.agent_server.tool_router import tool_router
93
94
  from openhands.agent_server.vscode_router import vscode_router
94
95
  from openhands.agent_server.vscode_service import get_vscode_service
95
- from openhands.agent_server.workspace_router import workspace_router
96
96
  from openhands.agent_server.workspaces_router import workspaces_router
97
97
  from openhands.sdk.logger import DEBUG, get_logger
98
98
  from openhands.sdk.utils.redact import sanitize_dict
@@ -160,6 +160,10 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
160
160
 
161
161
  config: Config = api.state.config
162
162
  deferred = config.deferred_init
163
+ conversation_registry = getattr(
164
+ api.state, "conversation_registry", None
165
+ ) or create_conversation_registry(config)
166
+ api.state.conversation_registry = conversation_registry
163
167
 
164
168
  # Deferred pods boot with telemetry disabled and are rebuilt by
165
169
  # InitService, so they emit `server_started` there instead.
@@ -254,6 +258,11 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
254
258
  bash_svc = get_default_bash_event_service()
255
259
  api.state.bash_event_service = bash_svc
256
260
 
261
+ conversation_registry.configure_service(service)
262
+ # Runtime cleanup must precede external-catalog recovery so stale
263
+ # runtime owners cannot lose their expired leases to the outer service.
264
+ await conversation_registry.start()
265
+
257
266
  async with service:
258
267
  api.state.conversation_service = service
259
268
 
@@ -273,6 +282,7 @@ async def api_lifespan(api: FastAPI) -> AsyncIterator[None]:
273
282
  try:
274
283
  yield
275
284
  finally:
285
+ await conversation_registry.shutdown()
276
286
  if retention_task is not None:
277
287
  retention_task.cancel()
278
288
  with suppress(asyncio.CancelledError):
@@ -388,6 +398,7 @@ def _find_http_exception(exc: BaseExceptionGroup) -> HTTPException | None:
388
398
 
389
399
  def _add_api_routes(app: FastAPI) -> None:
390
400
  """Add all API routes to the FastAPI application."""
401
+ conversation_registry = app.state.conversation_registry
391
402
  app.include_router(server_details_router)
392
403
 
393
404
  # The /api/init endpoint bypasses both the session-key auth and the
@@ -413,8 +424,10 @@ def _add_api_routes(app: FastAPI) -> None:
413
424
 
414
425
  api_router = APIRouter(prefix="/api", dependencies=dependencies)
415
426
  api_router.include_router(file_discovery_router)
416
- api_router.include_router(create_runtime_router())
417
- api_router.include_router(event_router)
427
+ # Collection routes must precede runtime catch-alls such as
428
+ # ``/conversations/{conversation_id}``.
429
+ api_router.include_router(conversation_catalog_router)
430
+ conversation_registry.add_execution_routes(api_router)
418
431
  api_router.include_router(conversation_router)
419
432
  api_router.include_router(credential_binding_router)
420
433
  api_router.include_router(tool_router)
@@ -422,7 +435,6 @@ def _add_api_routes(app: FastAPI) -> None:
422
435
  api_router.include_router(git_router)
423
436
  api_router.include_router(file_router)
424
437
  api_router.include_router(vscode_router)
425
- api_router.include_router(desktop_router)
426
438
  api_router.include_router(skills_router)
427
439
  api_router.include_router(sub_agents_router)
428
440
  api_router.include_router(plugins_router)
@@ -438,8 +450,6 @@ def _add_api_routes(app: FastAPI) -> None:
438
450
  # /api/auth/* mints workspace cookies and requires the header to bootstrap,
439
451
  # so it lives under the header-only auth group.
440
452
  api_router.include_router(auth_router)
441
- app.include_router(api_router)
442
-
443
453
  app.include_router(openai_router, dependencies=[Depends(check_openai_api_key)])
444
454
 
445
455
  # Workspace static-file routes get their own auth group that accepts
@@ -450,12 +460,11 @@ def _add_api_routes(app: FastAPI) -> None:
450
460
  workspace_api_router = APIRouter(
451
461
  prefix="/api", dependencies=[Depends(check_workspace_session)]
452
462
  )
453
- workspace_api_router.include_router(workspace_router)
463
+ workspace_api_router.include_router(conversation_registry.workspace_router)
454
464
  app.include_router(workspace_api_router)
465
+ app.include_router(api_router)
455
466
 
456
- app.include_router(sockets_router)
457
-
458
- app.include_router(session_router)
467
+ app.include_router(conversation_registry.sockets_router)
459
468
 
460
469
 
461
470
  def _setup_static_files(app: FastAPI, config: Config) -> None:
@@ -683,6 +692,7 @@ def create_app(config: Config | None = None) -> FastAPI:
683
692
  config = get_default_config()
684
693
  app = _create_fastapi_instance(config)
685
694
  app.state.config = config
695
+ app.state.conversation_registry = create_conversation_registry(config)
686
696
 
687
697
  _add_api_routes(app)
688
698
  _setup_static_files(app, config)
@@ -346,6 +346,13 @@ class Config(BaseModel):
346
346
  "The URL where this agent server instance is available externally"
347
347
  ),
348
348
  )
349
+ conversation_runtime: Literal["local", "docker"] = "local"
350
+ conversation_image: str = "ghcr.io/openhands/agent-server:latest-python"
351
+ conversation_container_memory: str | None = "4g"
352
+ conversation_container_cpus: float | None = Field(default=2.0, gt=0)
353
+ conversation_container_pids_limit: int | None = Field(default=512, gt=0)
354
+ conversation_container_startup_timeout: float = Field(default=120, gt=0)
355
+
349
356
  acp_skill_sourcing: ACPSkillSourcing = Field(
350
357
  default="native",
351
358
  description=(
@@ -0,0 +1,74 @@
1
+ """Select and manage the configured conversation runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from openhands.agent_server.config import Config
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ from openhands.agent_server.conversation_service import ConversationService
14
+
15
+
16
+ class ConversationRegistry:
17
+ """Route and lifecycle adapter for host-local conversations."""
18
+
19
+ def __init__(self, config: Config) -> None:
20
+ self.config = config
21
+
22
+ def configure_service(self, service: ConversationService) -> None:
23
+ """Connect runtime-specific persistence to the shared catalog."""
24
+
25
+ async def start(self) -> None:
26
+ """Start resources owned by this registry."""
27
+
28
+ async def shutdown(self) -> None:
29
+ """Stop resources owned by this registry."""
30
+
31
+ def add_execution_routes(self, router: APIRouter) -> None:
32
+ from openhands.agent_server.event_router import event_router
33
+ from openhands.agent_server.runtime_router import create_runtime_router
34
+
35
+ router.include_router(create_runtime_router())
36
+ router.include_router(event_router)
37
+
38
+ @property
39
+ def workspace_router(self) -> APIRouter:
40
+ from openhands.agent_server.workspace_router import workspace_router
41
+
42
+ return workspace_router
43
+
44
+ @property
45
+ def conversation_sockets_router(self) -> APIRouter:
46
+ from openhands.agent_server.sockets import conversation_sockets_router
47
+
48
+ return conversation_sockets_router
49
+
50
+ @property
51
+ def session_sockets_router(self) -> APIRouter:
52
+ from openhands.agent_server.session_socket import session_router
53
+
54
+ return session_router
55
+
56
+ @property
57
+ def sockets_router(self) -> APIRouter:
58
+ from openhands.agent_server.sockets import bash_sockets_router
59
+
60
+ router = APIRouter()
61
+ router.include_router(self.conversation_sockets_router)
62
+ router.include_router(self.session_sockets_router)
63
+ router.include_router(bash_sockets_router)
64
+ return router
65
+
66
+
67
+ def create_conversation_registry(config: Config) -> ConversationRegistry:
68
+ if config.conversation_runtime == "docker":
69
+ from openhands.agent_server.docker_runtime.registry import (
70
+ DockerConversationRegistry,
71
+ )
72
+
73
+ return DockerConversationRegistry(config)
74
+ return ConversationRegistry(config)
@@ -63,6 +63,7 @@ from openhands.sdk.workspace import LocalWorkspace
63
63
  from openhands.tools.preset.default import get_default_tools
64
64
 
65
65
 
66
+ conversation_catalog_router = APIRouter(prefix="/conversations", tags=["Conversations"])
66
67
  conversation_router = APIRouter(prefix="/conversations", tags=["Conversations"])
67
68
 
68
69
  # Examples
@@ -88,6 +89,7 @@ START_CONVERSATION_EXAMPLES = [
88
89
  # Read methods
89
90
 
90
91
 
92
+ @conversation_catalog_router.get("/search", include_in_schema=False)
91
93
  @conversation_router.get("/search")
92
94
  async def search_conversations(
93
95
  page_id: Annotated[
@@ -128,6 +130,7 @@ async def search_conversations(
128
130
  return page
129
131
 
130
132
 
133
+ @conversation_catalog_router.get("/count", include_in_schema=False)
131
134
  @conversation_router.get("/count")
132
135
  async def count_conversations(
133
136
  status: Annotated[
@@ -693,6 +693,7 @@ class ConversationService:
693
693
  webhook_specs: list[WebhookSpec] = field(default_factory=list)
694
694
  session_api_key: str | None = field(default=None)
695
695
  cipher: Cipher | None = None
696
+ runtime_cipher_resolver: Callable[[UUID], Cipher] | None = None
696
697
  mcp_tool_provider: MCPToolProvider | None = None
697
698
  secrets_store: FileSecretsStore | None = None
698
699
  owner_instance_id: str = field(default_factory=lambda: uuid4().hex)
@@ -703,6 +704,7 @@ class ConversationService:
703
704
  default=Path("/tmp/conversation-worktrees")
704
705
  )
705
706
  acp_skill_sourcing: ACPSkillSourcing = "native"
707
+ sync_external_catalog: bool = False
706
708
  _event_services: dict[UUID, EventService] | None = field(default=None, init=False)
707
709
  _conversation_records: dict[UUID, _ConversationRecord] = field(
708
710
  default_factory=dict, init=False
@@ -726,16 +728,23 @@ class ConversationService:
726
728
  default_factory=dict, init=False
727
729
  )
728
730
 
729
- def _load_catalog_sync(self) -> dict[UUID, _ConversationRecord]:
731
+ def _load_catalog_sync(
732
+ self, conversation_id: UUID | None = None
733
+ ) -> dict[UUID, _ConversationRecord]:
730
734
  records: dict[UUID, _ConversationRecord] = {}
731
- for conversation_dir in self.conversations_dir.iterdir():
735
+ directories = (
736
+ [self.conversations_dir / conversation_id.hex]
737
+ if conversation_id is not None
738
+ else self.conversations_dir.iterdir()
739
+ )
740
+ for conversation_dir in directories:
732
741
  meta_file = conversation_dir / "meta.json"
733
742
  if not meta_file.exists():
734
743
  continue
735
744
  try:
736
745
  stored = StoredConversation.model_validate_json(
737
746
  meta_file.read_text(),
738
- context={"cipher": self.cipher},
747
+ context={"cipher": self._cipher_for(UUID(conversation_dir.name))},
739
748
  )
740
749
  execution_status = ConversationExecutionStatus.IDLE
741
750
  base_state_file = conversation_dir / BASE_STATE
@@ -778,13 +787,19 @@ class ConversationService:
778
787
  record.base_state_path = path
779
788
  return path
780
789
 
790
+ def _cipher_for(self, conversation_id: UUID) -> Cipher | None:
791
+ if self.runtime_cipher_resolver is not None:
792
+ return self.runtime_cipher_resolver(conversation_id)
793
+ return self.cipher
794
+
781
795
  def _load_persisted_state_sync(
782
796
  self, conversation_id: UUID
783
797
  ) -> ConversationState | None:
784
798
  base_state_file = self.conversations_dir / conversation_id.hex / BASE_STATE
785
799
  if not base_state_file.exists():
786
800
  return None
787
- context = {"cipher": self.cipher} if self.cipher else None
801
+ cipher = self._cipher_for(conversation_id)
802
+ context = {"cipher": cipher} if cipher else None
788
803
  return ConversationState.model_validate_json(
789
804
  base_state_file.read_text(), context=context
790
805
  )
@@ -1093,16 +1108,34 @@ class ConversationService:
1093
1108
  record.cached_info = None
1094
1109
  record.state_signature = signature
1095
1110
 
1096
- async def _reconcile_active_records(self) -> None:
1097
- """Fill catalog entries for services injected outside normal startup.
1098
-
1099
- Normal service lifecycle paths maintain the catalog themselves. This
1100
- small reconciliation keeps direct embedders and existing test fixtures
1101
- that populate ``_event_services`` compatible.
1102
- """
1111
+ async def _reconcile_active_records(
1112
+ self, conversation_id: UUID | None = None
1113
+ ) -> None:
1114
+ """Discover externally persisted records and injected live services."""
1103
1115
  event_services = self._event_services
1104
1116
  if event_services is None:
1105
1117
  raise ValueError("inactive_service")
1118
+ if self.sync_external_catalog:
1119
+ disk_records = await asyncio.to_thread(
1120
+ self._load_catalog_sync, conversation_id
1121
+ )
1122
+ stale_ids = (
1123
+ {conversation_id}
1124
+ if conversation_id is not None
1125
+ else set(self._conversation_records)
1126
+ ) - set(disk_records)
1127
+ for record_id in stale_ids:
1128
+ event_service = event_services.get(record_id)
1129
+ if event_service is None or not event_service.is_open():
1130
+ self._conversation_records.pop(record_id, None)
1131
+ for record_id, record in disk_records.items():
1132
+ event_service = event_services.get(record_id)
1133
+ if event_service is not None and event_service.is_open():
1134
+ continue
1135
+ existing = self._conversation_records.setdefault(record_id, record)
1136
+ if existing.stored != record.stored:
1137
+ existing.stored = record.stored
1138
+ existing.cached_info = None
1106
1139
  for conversation_id, event_service in event_services.items():
1107
1140
  if conversation_id in self._conversation_records:
1108
1141
  continue
@@ -1237,6 +1270,8 @@ class ConversationService:
1237
1270
  async def get_conversation(self, conversation_id: UUID) -> ConversationInfo | None:
1238
1271
  if self._event_services is None:
1239
1272
  raise ValueError("inactive_service")
1273
+ if self.sync_external_catalog:
1274
+ await self._reconcile_active_records(conversation_id)
1240
1275
  record = self._conversation_records.get(conversation_id)
1241
1276
  if record is None:
1242
1277
  event_service = self._event_services.get(conversation_id)
@@ -1615,7 +1650,12 @@ class ConversationService:
1615
1650
  # Profile resolution and the load_memory stamp must happen before
1616
1651
  # _prepare_request_workspace (which asserts request.agent is not None)
1617
1652
  # and before model_dump so the resolved agent is captured in request_data.
1618
- launched_agent_profile: LaunchedAgentProfile | None = None
1653
+ runtime_profile = os.getenv("OH_RUNTIME_LAUNCHED_PROFILE")
1654
+ launched_agent_profile = (
1655
+ LaunchedAgentProfile.model_validate_json(runtime_profile)
1656
+ if runtime_profile
1657
+ else None
1658
+ )
1619
1659
 
1620
1660
  from openhands.agent_server.persistence import (
1621
1661
  PersistedSettings,
@@ -2386,6 +2426,7 @@ class ConversationService:
2386
2426
  conversation_idle_ttl_seconds=config.conversation_idle_ttl_seconds,
2387
2427
  conversation_worktree_root=config.conversation_worktree_root,
2388
2428
  acp_skill_sourcing=config.acp_skill_sourcing,
2429
+ sync_external_catalog=False,
2389
2430
  )
2390
2431
 
2391
2432
  async def _start_event_service(
@@ -398,7 +398,7 @@ class BuildOptions(BaseModel):
398
398
  default="", description="Comma-separated list of custom tags."
399
399
  )
400
400
  image: str = Field(default="ghcr.io/openhands/agent-server")
401
- image_flavor: Literal["default", "slim"] = Field(default="default")
401
+ image_flavor: Literal["default", "slim", "minimal"] = Field(default="default")
402
402
  target: TargetType = Field(default="binary")
403
403
  platforms: list[PlatformType] = Field(default=["linux/amd64"])
404
404
  push: bool | None = Field(
@@ -600,8 +600,10 @@ class BuildOptions(BaseModel):
600
600
  for versioned_tag in self.versioned_tags:
601
601
  tags.append(f"{self.image}:{versioned_tag}{arch_suffix}")
602
602
 
603
- # Append target suffix for clarity (binary is default, no suffix needed)
604
- if self.target != "binary":
603
+ # The minimal image flavor already names its binary-minimal target.
604
+ if self.target != "binary" and not (
605
+ self.target == "binary-minimal" and self.image_flavor == "minimal"
606
+ ):
605
607
  tags = [f"{t}-{self.target}" for t in tags]
606
608
  return list(dict.fromkeys(tags))
607
609
 
@@ -1083,7 +1085,7 @@ def main(argv: list[str]) -> int:
1083
1085
  parser.add_argument(
1084
1086
  "--image-flavor",
1085
1087
  default=_env("IMAGE_FLAVOR", "default"),
1086
- choices=("default", "slim"),
1088
+ choices=("default", "slim", "minimal"),
1087
1089
  help="Image flavor (default from $IMAGE_FLAVOR).",
1088
1090
  )
1089
1091
  parser.add_argument(
@@ -0,0 +1 @@
1
+ """Per-conversation Docker runtime support."""
@@ -0,0 +1,112 @@
1
+ """Prepare a conversation request for an isolated agent-server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+
9
+ from pydantic import SecretStr
10
+
11
+ from openhands.agent_server.config import Config
12
+ from openhands.agent_server.conversation_service import (
13
+ _resolve_agent_from_profile,
14
+ _with_load_memory,
15
+ )
16
+ from openhands.agent_server.docker_runtime.provisioning import RuntimeIdentity
17
+ from openhands.agent_server.persistence import PersistedSettings, get_settings_store
18
+ from openhands.sdk.agent.base import AgentBase
19
+ from openhands.sdk.conversation.request import StartConversationRequest
20
+ from openhands.sdk.conversation.secret_registry import SecretRegistry
21
+ from openhands.sdk.profiles.agent_profile import LaunchedAgentProfile
22
+ from openhands.sdk.secret import SecretSource, SecretValue, StaticSecret
23
+ from openhands.sdk.settings.model import validate_agent_settings
24
+
25
+
26
+ def materialize_secrets(
27
+ secrets: Mapping[str, SecretValue],
28
+ ) -> dict[str, SecretSource]:
29
+ """Resolve the SDK's secret sources before crossing a runtime boundary."""
30
+ registry = SecretRegistry()
31
+ registry.update_secrets(secrets)
32
+ return {
33
+ name: StaticSecret(
34
+ value=SecretStr(value)
35
+ if (value := registry.get_secret_value(name))
36
+ else None,
37
+ description=source.description,
38
+ )
39
+ for name, source in registry.secret_sources.items()
40
+ }
41
+
42
+
43
+ def _materialize_agent_context(agent: AgentBase) -> AgentBase:
44
+ context = agent.agent_context
45
+ if context is None or not context.secrets:
46
+ return agent
47
+ return agent.model_copy(
48
+ update={
49
+ "agent_context": context.model_copy(
50
+ update={"secrets": materialize_secrets(context.secrets)}
51
+ )
52
+ }
53
+ )
54
+
55
+
56
+ async def prepare_start(
57
+ body: dict[str, Any], config: Config
58
+ ) -> tuple[StartConversationRequest, LaunchedAgentProfile | None]:
59
+ body = {
60
+ name: value
61
+ for name, value in body.items()
62
+ if value is not None or name not in {"agent", "agent_settings"}
63
+ }
64
+ context = {"cipher": config.cipher} if body.get("secrets_encrypted") else None
65
+ if body.get("agent_settings") is not None:
66
+ settings = validate_agent_settings(body["agent_settings"], context=context)
67
+ body = {**body, "agent": settings.create_agent(), "agent_settings": None}
68
+ request = StartConversationRequest.model_validate(body, context=context)
69
+
70
+ try:
71
+ settings = await asyncio.to_thread(get_settings_store(config).load)
72
+ except (OSError, PermissionError):
73
+ settings = None
74
+ settings = settings or PersistedSettings()
75
+ launched = None
76
+ if request.agent_profile_id is not None:
77
+ agent, launched, allowed = await asyncio.to_thread(
78
+ _resolve_agent_from_profile,
79
+ request.agent_profile_id,
80
+ config.cipher,
81
+ settings.agent_settings.mcp_config,
82
+ acp_skill_sourcing=config.acp_skill_sourcing,
83
+ )
84
+ secrets = request.secrets
85
+ if allowed is not None:
86
+ secrets = {
87
+ name: value for name, value in secrets.items() if name in allowed
88
+ }
89
+ request = request.model_copy(
90
+ update={"agent": agent, "agent_profile_id": None, "secrets": secrets}
91
+ )
92
+
93
+ context_settings = settings.agent_settings.agent_context
94
+ if context_settings is not None and context_settings.load_memory:
95
+ request = request.model_copy(update={"agent": _with_load_memory(request.agent)})
96
+ request = request.model_copy(
97
+ update={
98
+ "agent": await asyncio.to_thread(_materialize_agent_context, request.agent),
99
+ "secrets": await asyncio.to_thread(materialize_secrets, request.secrets),
100
+ }
101
+ )
102
+ return request, launched
103
+
104
+
105
+ def serialize_start(
106
+ request: StartConversationRequest, identity: RuntimeIdentity
107
+ ) -> dict[str, Any]:
108
+ payload = request.model_dump(
109
+ mode="json", context={"cipher": identity.cipher}, exclude={"agent_profile_id"}
110
+ )
111
+ payload["secrets_encrypted"] = True
112
+ return payload